1977 lines
68 KiB
Go
1977 lines
68 KiB
Go
package helpers
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||
"github.com/spf13/cobra"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||
)
|
||
|
||
const (
|
||
reportDingtalkOpenLinkText = "在钉钉中查看日志"
|
||
reportDingtalkOpenLinkDescription = "点击后打开钉钉客户端的日志详情页,可查看或修改刚创建的日志。"
|
||
reportContentsMaxBytes = 10 * 1024 * 1024
|
||
reportDispatchTemplateSuccessHint = "dws report template get --name <模板名> --format json"
|
||
reportDispatchTemplateDetailHint = "dws report entry submit --template-id <templateId> --contents-file <tmp.json> --format json"
|
||
reportDispatchCreateHint = "dws report template list --format json\n dws report template get --name <模板名> --format json\n dws report entry submit --template-id <templateId> --contents-file <tmp.json> --format json"
|
||
reportDispatchDetailHint = "dws report outbox list --cursor 0 --size 20 --format json\n dws report entry get --report-id <reportId> --format json"
|
||
reportDispatchStatsHint = "dws report outbox list --cursor 0 --size 20 --format json\n dws report entry stats --report-id <reportId> --format json"
|
||
reportDispatchListHint = "dws report inbox list --start \"YYYY-MM-DDT00:00:00+08:00\" --end \"YYYY-MM-DDT23:59:59+08:00\" --cursor 0 --size 20 --format json"
|
||
reportDispatchOutboxListHint = "dws report outbox list --cursor 0 --size 20 --format json"
|
||
reportDispatchTemplateListHint = "dws report template get --name <模板名> --format json"
|
||
reportDispatchAuthFailureHint = "dws auth login"
|
||
)
|
||
|
||
var (
|
||
reportOpenFile = os.Open
|
||
reportAbsPath = filepath.Abs
|
||
reportGetwd = os.Getwd
|
||
reportEvalSymlinks = filepath.EvalSymlinks
|
||
reportStat = os.Stat
|
||
reportRelPath = filepath.Rel
|
||
)
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// dws report — 钉钉日志
|
||
// MCP tools: get_available_report_templates, get_template_details_by_name,
|
||
// create_report, get_report_entry_details, get_received_report_list,
|
||
// get_report_statistics_by_id, get_send_report_list
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
func newReportCommand() *cobra.Command {
|
||
// Product-level Agent routing Decl (migrated from selection/report.json
|
||
// products.report). Catalog assembly stamps provenance contract_final.
|
||
contract.RegisterProductDecl(contract.ProductDecl{
|
||
ID: "report",
|
||
Selection: contract.ProductSelectionDecl{
|
||
AgentSummary: "查询日志模板、收发日志、日志正文与统计,并按模板提交日志",
|
||
UseWhen: []string{
|
||
"查看或提交日报、周报等钉钉日志时",
|
||
},
|
||
AvoidWhen: []string{
|
||
"不要用于待办任务、OA 审批或在线文档正文编辑",
|
||
},
|
||
},
|
||
})
|
||
root := &cobra.Command{
|
||
Use: "report",
|
||
Aliases: []string{"log"},
|
||
Short: "钉钉日志(OA 周报应用 / 日志模版填报)",
|
||
Long: `钉钉日志(OA 周报应用 / 日志模版填报)— 模版 / 提交 / 收件箱 / 发件箱 / 单条详情 / 统计。
|
||
|
||
载体辨义:本命令族管理「钉钉日志」OA 应用(按模版填报、收件箱列表、发件箱列表、统计),不是钉钉在线文档。
|
||
若你想要的是「钉钉在线文档」请使用 dws doc;想要「待办事项」请使用 dws todo。
|
||
|
||
资源.动词命令树(与 mail 对齐):
|
||
template list 列出可用模版
|
||
template get 读取单个模版的字段定义
|
||
inbox list 列出我收到的日报
|
||
outbox list 列出我发出的日报
|
||
entry get 读取单份日报正文
|
||
entry stats 读取单份日报的已读统计
|
||
entry submit 按模版提交一份新日报
|
||
|
||
别名:dws log 等价 dws report(注意:此处 log 特指 OA 周报应用,不是通用日志/记录)。`,
|
||
RunE: groupRunE,
|
||
}
|
||
|
||
// === template subtree(template list 不变;新增 template get;template detail 转 deprecated alias)===
|
||
templateCmd := &cobra.Command{Use: "template", Short: "日志模版", RunE: groupRunE}
|
||
|
||
templateListCmd := &cobra.Command{
|
||
Use: "list",
|
||
Short: "获取当前用户可用的日志模版列表",
|
||
Example: ` dws report template list`,
|
||
RunE: runReportTemplateList,
|
||
}
|
||
DeclareLeafMetadata(templateListCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "report",
|
||
Name: "get_available_report_templates",
|
||
CanonicalPath: "report.get_available_report_templates",
|
||
CLIPath: "report template list",
|
||
PrimaryCLIPath: "report template list",
|
||
},
|
||
Description: "获取当前员工可使用的日志模版列表",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "report", RPCName: "get_available_report_templates"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "获取当前员工可使用的日志模版列表",
|
||
UseWhen: []string{"提交日志前需要列出可用模版名称与 templateId 时"},
|
||
AvoidWhen: []string{"已知模版名称需要字段定义时改用 dws report template get"},
|
||
Examples: []string{
|
||
"dws report template list",
|
||
"dws report template list --format json",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
|
||
templateGetCmd := &cobra.Command{
|
||
Use: "get",
|
||
Short: "读取单个日志模版的字段定义",
|
||
Example: ` dws report template get --name <templateName>`,
|
||
RunE: runReportTemplateDetail,
|
||
}
|
||
DeclareLeafMetadata(templateGetCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "report",
|
||
Name: "get_template_details_by_name",
|
||
CanonicalPath: "report.get_template_details_by_name",
|
||
CLIPath: "report template get",
|
||
PrimaryCLIPath: "report template get",
|
||
Aliases: []string{"report template detail"},
|
||
},
|
||
Description: "按名称获取日志模版字段定义",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "report", RPCName: "get_template_details_by_name"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "按名称获取日志模版字段定义",
|
||
UseWhen: []string{"已知模版名称,提交前需要读取字段名称、类型与排序时"},
|
||
AvoidWhen: []string{"不知道有哪些模版时先用 dws report template list"},
|
||
Examples: []string{
|
||
"dws report template get --name <templateName>",
|
||
"dws report template get --name \"日报\" --format json",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "name", Property: "report_template_name"},
|
||
},
|
||
},
|
||
})
|
||
addReportTemplateDetailFlags(templateGetCmd)
|
||
|
||
templateDetailCmd := &cobra.Command{
|
||
Use: "detail",
|
||
Short: "[deprecated] 已废弃,请改用 `dws report template get`",
|
||
Example: ` dws report template detail --name <templateName>`,
|
||
RunE: withReportDeprecationWarning("template detail", "template get", runReportTemplateDetail),
|
||
}
|
||
addReportTemplateDetailFlags(templateDetailCmd)
|
||
|
||
templateCmd.AddCommand(templateListCmd, templateGetCmd, templateDetailCmd)
|
||
|
||
// === entry subtree(单条日报操作 — get / stats / submit)===
|
||
entryCmd := &cobra.Command{Use: "entry", Short: "日志条目(单条日报操作 — get / stats / submit)", RunE: groupRunE}
|
||
|
||
entryGetCmd := &cobra.Command{
|
||
Use: "get",
|
||
Short: "读取单份日报正文(含字段明细 + 钉钉跳转链接)",
|
||
Example: ` # 查询 reportId: dws report inbox list / outbox list
|
||
dws report entry get --report-id <reportId>`,
|
||
RunE: runReportDetail,
|
||
}
|
||
DeclareLeafMetadata(entryGetCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "report",
|
||
Name: "get_report_entry_details",
|
||
CanonicalPath: "report.get_report_entry_details",
|
||
CLIPath: "report entry get",
|
||
PrimaryCLIPath: "report entry get",
|
||
Aliases: []string{"report detail"},
|
||
},
|
||
Description: "获取指定一篇日志的详情信息",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "report", RPCName: "get_report_entry_details"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "获取指定一篇日志的详情信息",
|
||
UseWhen: []string{"已知 reportId,需要读取日志正文、字段明细或钉钉跳转链接时"},
|
||
AvoidWhen: []string{
|
||
"要提交新日志时改用 dws report entry submit",
|
||
"要看已读/评论统计时改用 dws report entry stats",
|
||
},
|
||
Examples: []string{
|
||
"dws report entry get --report-id <reportId> --format json",
|
||
"dws report entry get --report-id <reportId>",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "report-id", Property: "report_id"},
|
||
},
|
||
},
|
||
})
|
||
addReportDetailFlags(entryGetCmd)
|
||
|
||
entryStatsCmd := &cobra.Command{
|
||
Use: "stats",
|
||
Short: "读取单份日报的已读统计",
|
||
Example: ` # 查询 reportId: dws report inbox list / outbox list
|
||
dws report entry stats --report-id <reportId>`,
|
||
RunE: runReportStats,
|
||
}
|
||
DeclareLeafMetadata(entryStatsCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "report",
|
||
Name: "get_report_statistics_by_id",
|
||
CanonicalPath: "report.get_report_statistics_by_id",
|
||
CLIPath: "report entry stats",
|
||
PrimaryCLIPath: "report entry stats",
|
||
Aliases: []string{"report stats"},
|
||
},
|
||
Description: "获取指定日志的统计数据",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "report", RPCName: "get_report_statistics_by_id"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "获取指定日志的统计数据",
|
||
UseWhen: []string{"已知 reportId,需要查看评论数、点赞数、已读数等统计时"},
|
||
AvoidWhen: []string{
|
||
"要读正文时改用 dws report entry get",
|
||
"要提交新日志时改用 dws report entry submit",
|
||
},
|
||
Examples: []string{
|
||
"dws report entry stats --report-id <reportId>",
|
||
"dws report entry stats --report-id <reportId> --format json",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "report-id", Property: "report_id"},
|
||
},
|
||
},
|
||
})
|
||
addReportStatsFlags(entryStatsCmd)
|
||
|
||
entrySubmitCmd := &cobra.Command{
|
||
Use: "submit",
|
||
Short: "提交一份新日报(按模版)",
|
||
Long: `按模版提交一份日报。--contents 为 JSON 数组,每项需含 key、sort、content、contentType、type,
|
||
与远程 create_report 一致;可先通过 report template list / template get 取得 templateId 与控件定义。
|
||
|
||
长内容(含中文换行 / Markdown)建议走 --contents-file 避免 shell 引号问题;
|
||
也可用 --contents - 从 stdin 读取。
|
||
提交成功后会自动反查详情,并在返回中追加 dingtalkOpenUrl / dingtalkOpenMarkdownLink 跳转链接字段。`,
|
||
Example: ` dws report entry submit --template-id TPL_ID --contents '[{"content":"完成开发","sort":"0","key":"今日完成","contentType":"markdown","type":"1"}]'
|
||
# 推荐:长内容走文件
|
||
dws report entry submit --template-id TPL_ID --contents-file ./report.json
|
||
# 或 stdin
|
||
cat report.json | dws report entry submit --template-id TPL_ID --contents -
|
||
dws report entry submit --template-id TPL_ID --contents '[...]' --to-chat --to-user-ids userId1,userId2`,
|
||
RunE: runReportCreate,
|
||
}
|
||
DeclareLeafMetadata(entrySubmitCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "report",
|
||
Name: "create_report",
|
||
CanonicalPath: "report.create_report",
|
||
CLIPath: "report entry submit",
|
||
PrimaryCLIPath: "report entry submit",
|
||
Aliases: []string{"report create"},
|
||
},
|
||
Description: "按模版提交一份新日报",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "report", RPCName: "create_report"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "按模版提交一份新日报",
|
||
UseWhen: []string{"已取得 templateId 与字段定义,需要按模版提交日报/周报(contents[].key 必须等于模板 field_name)时"},
|
||
AvoidWhen: []string{
|
||
"尚未读取模板字段时先用 dws report template list / template get",
|
||
"只需查看已有日志正文时改用 dws report entry get",
|
||
},
|
||
Examples: []string{
|
||
"dws report entry submit --template-id <templateId> --contents-file ./report.json --format json",
|
||
"dws report entry submit --template-id <templateId> --contents '[{\"key\":\"今日完成\",\"sort\":\"0\",\"content\":\"完成了需求评审\",\"contentType\":\"markdown\",\"type\":\"1\"}]' --format json",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "to-chat", Required: boolPtr(false)},
|
||
},
|
||
},
|
||
})
|
||
addReportCreateFlags(entrySubmitCmd)
|
||
|
||
entryCmd.AddCommand(entryGetCmd, entryStatsCmd, entrySubmitCmd)
|
||
|
||
// === inbox subtree(我收到的日报)===
|
||
inboxCmd := &cobra.Command{
|
||
Use: "inbox",
|
||
Short: "收件箱(我收到的日报)",
|
||
Example: ` dws report inbox --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`,
|
||
RunE: withReportDeprecationWarning("inbox", "inbox list", runReportList),
|
||
}
|
||
addReportListFlags(inboxCmd)
|
||
|
||
inboxListCmd := &cobra.Command{
|
||
Use: "list",
|
||
Short: "列出我收到的日报",
|
||
Example: ` dws report inbox list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20
|
||
# 时间 flag 只能是 --start / --end;禁止 --start-date / --end-date / --date
|
||
# CLI 只返回 JSON,Agent 从 result[] 拼 Markdown 表展示给用户,reportId 仅用于 entry get/stats`,
|
||
RunE: runReportList,
|
||
}
|
||
DeclareLeafMetadata(inboxListCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "report",
|
||
Name: "get_received_report_list",
|
||
CanonicalPath: "report.get_received_report_list",
|
||
CLIPath: "report inbox list",
|
||
PrimaryCLIPath: "report inbox list",
|
||
Aliases: []string{"report list"},
|
||
},
|
||
Description: "查询当前人收到的日志列表",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "report", RPCName: "get_received_report_list"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "查询当前人收到的日志列表",
|
||
UseWhen: []string{"需要按时间范围查看别人发给我的日志列表并提取 reportId 时"},
|
||
AvoidWhen: []string{
|
||
"要看自己发出的日志时改用 dws report outbox list",
|
||
"已知 reportId 要看正文时改用 dws report entry get",
|
||
},
|
||
Examples: []string{"dws report inbox list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20 --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "start", Property: "startTime"},
|
||
{Name: "end", Property: "endTime"},
|
||
},
|
||
},
|
||
})
|
||
addReportListFlags(inboxListCmd)
|
||
|
||
inboxCmd.AddCommand(inboxListCmd)
|
||
|
||
// === outbox subtree(我发出的日报)===
|
||
outboxCmd := &cobra.Command{Use: "outbox", Short: "发件箱(我发出的日报)", RunE: groupRunE}
|
||
|
||
outboxListCmd := &cobra.Command{
|
||
Use: "list",
|
||
Short: "列出我发出的日报",
|
||
Example: ` dws report outbox list --cursor 0 --size 20
|
||
dws report outbox list --cursor 0 --size 20 --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00"
|
||
dws report outbox list --cursor 0 --size 20 --template-name "日报"`,
|
||
RunE: runReportSent,
|
||
}
|
||
DeclareLeafMetadata(outboxListCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "report",
|
||
Name: "get_send_report_list",
|
||
CanonicalPath: "report.get_send_report_list",
|
||
CLIPath: "report outbox list",
|
||
PrimaryCLIPath: "report outbox list",
|
||
Aliases: []string{"report created", "report sent"},
|
||
},
|
||
Description: "查询当前人创建的日志详情列表",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "report", RPCName: "get_send_report_list"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "查询当前人创建的日志详情列表",
|
||
UseWhen: []string{"需要查看我发出/创建的日志列表(含内容摘要、创建时间、访问地址)时"},
|
||
AvoidWhen: []string{
|
||
"要看收到的日志时改用 dws report inbox list",
|
||
"已知 reportId 要完整正文时改用 dws report entry get",
|
||
},
|
||
Examples: []string{
|
||
"dws report outbox list --cursor 0 --size 20 --format json",
|
||
"dws report outbox list --cursor 0 --size 20",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "start", Property: "startTime"},
|
||
{Name: "end", Property: "endTime"},
|
||
{Name: "modified-start", Property: "modifiedStartTime"},
|
||
{Name: "modified-end", Property: "modifiedEndTime"},
|
||
{Name: "template-name", Property: "report_template_name"},
|
||
},
|
||
},
|
||
})
|
||
addReportSentFlags(outboxListCmd)
|
||
|
||
outboxCmd.AddCommand(outboxListCmd)
|
||
|
||
// === 旧命令(保留作 deprecation alias,调用时打 stderr 警告,行为与新命令一致)===
|
||
createCmd := &cobra.Command{
|
||
Use: "create",
|
||
Short: "[deprecated] 已废弃,请改用 `dws report entry submit`",
|
||
Example: ` dws report create --template-id TPL_ID --contents-file ./report.json`,
|
||
RunE: withReportDeprecationWarning("create", "entry submit", runReportCreate),
|
||
}
|
||
addReportCreateFlags(createCmd)
|
||
// Mirror entry submit's reviewed --to-chat ParamDecl onto the deprecated
|
||
// create alias. Schema assembly still reads ContractFinal from the primary
|
||
// leaf; this keeps compatibility-equivalence NativeRequired annotations
|
||
// symmetric instead of relying only on one-sided tolerance.
|
||
// addReportCreateFlags always registers --to-chat; ApplyParamDecls cannot
|
||
// fail for this reviewed alias mirror.
|
||
_ = cli.ApplyParamDecls(createCmd, []contract.ParamDecl{
|
||
{Name: "to-chat", Required: boolPtr(false)},
|
||
})
|
||
|
||
detailCmd := &cobra.Command{
|
||
Use: "detail",
|
||
Short: "[deprecated] 已废弃,请改用 `dws report entry get`",
|
||
Example: ` dws report detail --report-id <reportId>`,
|
||
RunE: withReportDeprecationWarning("detail", "entry get", runReportDetail),
|
||
}
|
||
addReportDetailFlags(detailCmd)
|
||
|
||
listCmd := &cobra.Command{
|
||
Use: "list",
|
||
Short: "[deprecated] 已废弃,请改用 `dws report inbox list`",
|
||
Example: ` dws report list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`,
|
||
RunE: withReportDeprecationWarning("list", "inbox list", runReportList),
|
||
}
|
||
addReportListFlags(listCmd)
|
||
|
||
statsCmd := &cobra.Command{
|
||
Use: "stats",
|
||
Short: "[deprecated] 已废弃,请改用 `dws report entry stats`",
|
||
Example: ` dws report stats --report-id <reportId>`,
|
||
RunE: withReportDeprecationWarning("stats", "entry stats", runReportStats),
|
||
}
|
||
addReportStatsFlags(statsCmd)
|
||
|
||
sendListCmd := &cobra.Command{
|
||
Use: "sent",
|
||
Short: "[deprecated] 已废弃,请改用 `dws report outbox list`",
|
||
Example: ` dws report sent --cursor 0 --size 20`,
|
||
RunE: withReportDeprecationWarning("sent", "outbox list", runReportSent),
|
||
}
|
||
addReportSentFlags(sendListCmd)
|
||
|
||
createdListCmd := &cobra.Command{
|
||
Use: "created",
|
||
Short: "[deprecated] 已废弃,请改用 `dws report outbox list`",
|
||
Example: ` dws report created --cursor 0 --size 20`,
|
||
RunE: withReportDeprecationWarning("created", "outbox list", runReportSent),
|
||
}
|
||
addReportSentFlags(createdListCmd)
|
||
|
||
// These deprecated leaves wrap the same business handlers with a stderr
|
||
// warning. Keep the implementation-side equivalence review next to the
|
||
// command construction; Registry alias review alone cannot prove handlers
|
||
// do not inject a different request preset.
|
||
cli.AnnotateRuntimeCompatibilityEquivalence(templateGetCmd, templateDetailCmd, cli.RuntimeCompatibilityEquivalence{
|
||
ID: "report.template-get-detail-v1", Reason: "The deprecated detail leaf only adds a deprecation warning before the exact template get business handler.", Reviewed: true,
|
||
})
|
||
cli.AnnotateRuntimeCompatibilityEquivalence(entrySubmitCmd, createCmd, cli.RuntimeCompatibilityEquivalence{
|
||
ID: "report.entry-submit-create-v1", Reason: "The deprecated create leaf only adds a deprecation warning before the exact entry submit business handler.", Reviewed: true,
|
||
})
|
||
cli.AnnotateRuntimeCompatibilityEquivalence(entryGetCmd, detailCmd, cli.RuntimeCompatibilityEquivalence{
|
||
ID: "report.entry-get-detail-v1", Reason: "The deprecated detail leaf only adds a deprecation warning before the exact entry get business handler.", Reviewed: true,
|
||
})
|
||
cli.AnnotateRuntimeCompatibilityEquivalence(inboxListCmd, listCmd, cli.RuntimeCompatibilityEquivalence{
|
||
ID: "report.inbox-list-legacy-list-v1", Reason: "The deprecated list leaf only adds a deprecation warning before the exact inbox list business handler.", Reviewed: true,
|
||
})
|
||
cli.AnnotateRuntimeCompatibilityEquivalence(entryStatsCmd, statsCmd, cli.RuntimeCompatibilityEquivalence{
|
||
ID: "report.entry-stats-legacy-stats-v1", Reason: "The deprecated stats leaf only adds a deprecation warning before the exact entry stats business handler.", Reviewed: true,
|
||
})
|
||
cli.AnnotateRuntimeCompatibilityEquivalence(outboxListCmd, sendListCmd, cli.RuntimeCompatibilityEquivalence{
|
||
ID: "report.outbox-list-legacy-spellings-v1", Reason: "The deprecated sent and created leaves only add a deprecation warning before the exact outbox list business handler.", Reviewed: true,
|
||
})
|
||
cli.AnnotateRuntimeCompatibilityEquivalence(outboxListCmd, createdListCmd, cli.RuntimeCompatibilityEquivalence{
|
||
ID: "report.outbox-list-legacy-spellings-v1", Reason: "The deprecated sent and created leaves only add a deprecation warning before the exact outbox list business handler.", Reviewed: true,
|
||
})
|
||
|
||
root.AddCommand(
|
||
// 新命令(资源.动词二段式)
|
||
templateCmd,
|
||
entryCmd,
|
||
inboxCmd,
|
||
outboxCmd,
|
||
// 旧命令(deprecation aliases,下个版本逐步下线)
|
||
createCmd, detailCmd, listCmd, statsCmd, sendListCmd, createdListCmd,
|
||
)
|
||
return root
|
||
}
|
||
|
||
// --- 共享 handler / flag setter / deprecation 包装 ---
|
||
// 抽取自原 newReportCommand 内联的 RunE / Flags 调用,使新命令(inbox/outbox/entry/template get)
|
||
// 与旧命令(list/sent/detail/stats/create/template detail)共用同一份业务实现,保证逐字节等价。
|
||
|
||
func runReportTemplateList(cmd *cobra.Command, args []string) error {
|
||
return withReportDispatchHint("template-list", callMCPTool("get_available_report_templates", nil))
|
||
}
|
||
|
||
func runReportTemplateDetail(cmd *cobra.Command, args []string) error {
|
||
if err := validateRequiredFlags(cmd, "name"); err != nil {
|
||
return err
|
||
}
|
||
return withReportDispatchHint("template-detail", callMCPTool("get_template_details_by_name", map[string]any{
|
||
"report_template_name": mustGetFlag(cmd, "name"),
|
||
}))
|
||
}
|
||
|
||
func runReportCreate(cmd *cobra.Command, args []string) error {
|
||
tplID := mustGetFlag(cmd, "template-id")
|
||
if tplID == "" {
|
||
return &CLIError{
|
||
Code: CodeMissingParam,
|
||
Message: "flag --template-id is required",
|
||
Suggestion: "运行 `dws report template list --format json` 获取合法 templateId",
|
||
Operation: "report.create",
|
||
}
|
||
}
|
||
contentsJSON, err := resolveReportContentsFromFlags(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if strings.TrimSpace(contentsJSON) == "" {
|
||
return &CLIError{
|
||
Code: CodeMissingParam,
|
||
Message: "contents is required",
|
||
Suggestion: "通过 --contents '<json>' / --contents - (stdin) / --contents-file <path> 之一提供 contents JSON 数组",
|
||
Operation: "report.create",
|
||
}
|
||
}
|
||
var contents []map[string]any
|
||
if err := json.Unmarshal([]byte(contentsJSON), &contents); err != nil {
|
||
return &CLIError{
|
||
Code: CodeInvalidJSON,
|
||
Message: fmt.Sprintf("contents JSON parse failed: %v", err),
|
||
Suggestion: "contents 必须是 JSON 数组,每项含 key/sort/content/contentType/type;先看 template get 取字段名与类型",
|
||
Operation: "report.create",
|
||
}
|
||
}
|
||
if err := validateAndNormalizeReportContents(contents); err != nil {
|
||
return err
|
||
}
|
||
ddFrom := mustGetFlag(cmd, "dd-from")
|
||
if ddFrom == "" {
|
||
ddFrom = "dws"
|
||
}
|
||
toChat, _ := cmd.Flags().GetBool("to-chat")
|
||
toolArgs := map[string]any{
|
||
"templateId": tplID,
|
||
"contents": contents,
|
||
"ddFrom": ddFrom,
|
||
"toChat": toChat,
|
||
}
|
||
if v, _ := cmd.Flags().GetString("to-user-ids"); v != "" {
|
||
toolArgs["toUserIds"] = parseReportUserIDs(v)
|
||
}
|
||
return callReportCreateWithDetailURL(toolArgs)
|
||
}
|
||
|
||
func runReportDetail(cmd *cobra.Command, args []string) error {
|
||
if err := validateRequiredFlags(cmd, "report-id"); err != nil {
|
||
return err
|
||
}
|
||
return withReportDispatchHint("detail", callMCPTool("get_report_entry_details", map[string]any{
|
||
"report_id": mustGetFlag(cmd, "report-id"),
|
||
}))
|
||
}
|
||
|
||
func runReportList(cmd *cobra.Command, args []string) error {
|
||
if err := validateRequiredFlags(cmd, "start", "end"); err != nil {
|
||
return err
|
||
}
|
||
startStr := mustGetFlag(cmd, "start")
|
||
endStr := mustGetFlag(cmd, "end")
|
||
startMs, err := parseISOTimeToMillis("start", startStr)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
endMs, err := parseISOTimeToMillis("end", endStr)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := validateTimeRange(startMs, endMs); err != nil {
|
||
return err
|
||
}
|
||
cursor, _ := cmd.Flags().GetInt("cursor")
|
||
size, _ := cmd.Flags().GetInt("size")
|
||
if v, _ := cmd.Flags().GetInt("limit"); v > 0 && !cmd.Flags().Changed("size") {
|
||
size = v
|
||
}
|
||
toolArgs := map[string]any{
|
||
"startTime": float64(startMs),
|
||
"endTime": float64(endMs),
|
||
"cursor": float64(cursor),
|
||
"size": float64(size),
|
||
}
|
||
// 发送人过滤(来自 develop 分支 feature/select_report_staff)
|
||
if senderIDs, _ := cmd.Flags().GetStringSlice("sender-user-ids"); len(senderIDs) > 0 {
|
||
toolArgs["senderUserIds"] = senderIDs
|
||
}
|
||
return callReportListReadable("get_received_report_list", "list", toolArgs)
|
||
}
|
||
|
||
func runReportStats(cmd *cobra.Command, args []string) error {
|
||
if err := validateRequiredFlags(cmd, "report-id"); err != nil {
|
||
return err
|
||
}
|
||
return withReportDispatchHint("stats", callMCPTool("get_report_statistics_by_id", map[string]any{
|
||
"report_id": mustGetFlag(cmd, "report-id"),
|
||
}))
|
||
}
|
||
|
||
func runReportSent(cmd *cobra.Command, args []string) error {
|
||
cursor, _ := cmd.Flags().GetInt("cursor")
|
||
size, _ := cmd.Flags().GetInt("size")
|
||
if v, _ := cmd.Flags().GetInt("limit"); v > 0 && !cmd.Flags().Changed("size") {
|
||
size = v
|
||
}
|
||
toolArgs := map[string]any{
|
||
"cursor": float64(cursor),
|
||
"size": float64(size),
|
||
}
|
||
now := time.Now()
|
||
// 服务端 get_send_report_list 单次查询最大跨度为 20 天;
|
||
// 默认窗口与上限对齐,避免触发服务端范围错误。
|
||
startDefault := now.AddDate(0, 0, -20).Truncate(24 * time.Hour).Format(time.RFC3339)
|
||
endDefault := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).Format(time.RFC3339)
|
||
startStr, _ := cmd.Flags().GetString("start")
|
||
endStr, _ := cmd.Flags().GetString("end")
|
||
startMissing := startStr == ""
|
||
endMissing := endStr == ""
|
||
if startMissing {
|
||
startStr = startDefault
|
||
}
|
||
if endMissing {
|
||
endStr = endDefault
|
||
}
|
||
if startMissing || endMissing {
|
||
fmt.Fprintf(cmd.ErrOrStderr(),
|
||
"# info: --start / --end not provided, defaulting to last 20 days (%s ~ %s); server caps single-query span at 20 days, pass explicit --start to shift the window\n",
|
||
startStr, endStr)
|
||
}
|
||
startMs, err := parseISOTimeToMillis("start", startStr)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
toolArgs["startTime"] = float64(startMs)
|
||
endMs, err := parseISOTimeToMillis("end", endStr)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
toolArgs["endTime"] = float64(endMs)
|
||
if err := validateTimeRange(startMs, endMs); err != nil {
|
||
return err
|
||
}
|
||
if v, _ := cmd.Flags().GetString("modified-start"); v != "" {
|
||
ms, err := parseISOTimeToMillis("modified-start", v)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
toolArgs["modifiedStartTime"] = float64(ms)
|
||
}
|
||
if v, _ := cmd.Flags().GetString("modified-end"); v != "" {
|
||
ms, err := parseISOTimeToMillis("modified-end", v)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
toolArgs["modifiedEndTime"] = float64(ms)
|
||
}
|
||
if v, _ := cmd.Flags().GetString("template-name"); v != "" {
|
||
toolArgs["report_template_name"] = v
|
||
}
|
||
return callReportListReadable("get_send_report_list", "sent", toolArgs)
|
||
}
|
||
|
||
func addReportTemplateDetailFlags(cmd *cobra.Command) {
|
||
cmd.Flags().String("name", "", "模版名称 (必填)")
|
||
}
|
||
|
||
func addReportDetailFlags(cmd *cobra.Command) {
|
||
cmd.Flags().String("report-id", "", "日志 ID (必填)")
|
||
}
|
||
|
||
func addReportStatsFlags(cmd *cobra.Command) {
|
||
cmd.Flags().String("report-id", "", "日志 ID (必填)")
|
||
}
|
||
|
||
func addReportListFlags(cmd *cobra.Command) {
|
||
cmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填)")
|
||
cmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填)")
|
||
cmd.Flags().Int("cursor", 0, "分页游标(默认 0,翻页传返回的 cursor)")
|
||
cmd.Flags().Int("size", 20, "每页条数(默认 20,最大 20)")
|
||
cmd.Flags().Int("limit", 0, "--size 的别名")
|
||
_ = cmd.Flags().MarkHidden("limit")
|
||
// 发送人过滤(来自 develop 分支 feature/select_report_staff)
|
||
cmd.Flags().StringSlice("sender-user-ids", nil, "发送人 staffId 列表,逗号分隔,用于过滤指定发送人的日志")
|
||
}
|
||
|
||
func addReportSentFlags(cmd *cobra.Command) {
|
||
cmd.Flags().Int("cursor", 0, "分页游标,首次传 0 (默认 0)")
|
||
cmd.Flags().Int("size", 20, "每页条数,最大 20 (默认 20)")
|
||
cmd.Flags().Int("limit", 0, "--size 的别名")
|
||
_ = cmd.Flags().MarkHidden("limit")
|
||
cmd.Flags().String("start", "", "创建开始时间 ISO-8601 (默认最近 20 天;服务端单次查询跨度上限 20 天)")
|
||
cmd.Flags().String("end", "", "创建结束时间 ISO-8601 (默认最近 20 天;服务端单次查询跨度上限 20 天)")
|
||
cmd.Flags().String("modified-start", "", "修改开始时间 ISO-8601 (可选)")
|
||
cmd.Flags().String("modified-end", "", "修改结束时间 ISO-8601 (可选)")
|
||
cmd.Flags().String("template-name", "", "日志模板名称 (可选,不传查全部)")
|
||
}
|
||
|
||
func addReportCreateFlags(cmd *cobra.Command) {
|
||
cmd.Flags().String("template-id", "", "日志模版 ID (必填)")
|
||
cmd.Flags().String("contents", "", "日志内容 JSON 数组 (必填,或用 --contents-file),每项含 key/sort/content/contentType/type;传 - 表示从 stdin 读取")
|
||
cmd.Flags().String("contents-file", "", "从文件读取 contents JSON(推荐用于含中文/换行/Markdown 的长内容,避免 shell 引号转义;优先级:--contents-file > --contents - (stdin) > --contents '<json>')")
|
||
cmd.Flags().String("dd-from", "dws", "创建来源标识")
|
||
cmd.Flags().Bool("to-chat", false, "是否发送到日志接收人单聊")
|
||
cmd.Flags().String("to-user-ids", "", "接收人 userId,逗号分隔 (可选)")
|
||
}
|
||
|
||
// withReportDeprecationWarning 包装旧命令的 RunE:调用时往 stderr 打废弃提醒,
|
||
// 然后透传到新命令的 handler,stdout 与 exit code 与新命令保持一致。
|
||
// 文案不带具体下线日期,避免 Phase 3 延后时需要再发一版改文案。
|
||
func withReportDeprecationWarning(oldName, newName string, run func(*cobra.Command, []string) error) func(*cobra.Command, []string) error {
|
||
return func(cmd *cobra.Command, args []string) error {
|
||
fmt.Fprintf(cmd.ErrOrStderr(),
|
||
"[deprecated] `dws report %s` 已废弃,将在后续版本中移除。请改用 `dws report %s`。\n",
|
||
oldName, newName)
|
||
return run(cmd, args)
|
||
}
|
||
}
|
||
|
||
// parseReportUserIDs 将 "id1,id2" 转为 []string,供 create_report 的 toUserIds 使用。
|
||
func parseReportUserIDs(s string) []string {
|
||
parts := strings.Split(s, ",")
|
||
out := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
p = strings.TrimSpace(p)
|
||
if p != "" {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func callReportListReadable(toolName, operation string, toolArgs map[string]any) error {
|
||
if deps.Caller.DryRun() {
|
||
return withReportDispatchHint(operation, callMCPTool(toolName, toolArgs))
|
||
}
|
||
|
||
ctx := context.Background()
|
||
text, err := callMCPToolReturnTextOnServer(ctx, "report", toolName, toolArgs)
|
||
if err != nil {
|
||
return withReportDispatchHint(operation, err)
|
||
}
|
||
var parsed any
|
||
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
|
||
deps.Out.PrintRaw(text)
|
||
return nil
|
||
}
|
||
enriched := enrichReportListReadable(ctx, operation, parsed)
|
||
return deps.Out.PrintJSONUnescaped(enriched)
|
||
}
|
||
|
||
func enrichReportListReadable(ctx context.Context, operation string, parsed any) any {
|
||
body, ok := parsed.(map[string]any)
|
||
if !ok {
|
||
return parsed
|
||
}
|
||
items, found := findReportListItems(body)
|
||
if !found {
|
||
return body
|
||
}
|
||
includeContent := reportListIncludesContent(operation)
|
||
entries := makeReportReadableEntries(items)
|
||
sortReportReadableEntries(entries)
|
||
rows := make([]map[string]string, 0, len(items))
|
||
detailCommands := make([]map[string]any, 0, len(items))
|
||
for _, entry := range entries {
|
||
row := entry.row
|
||
if entry.reportID != "" {
|
||
detail := reportDetailForListRow(ctx, entry.reportID, includeContent)
|
||
mergeReportListDetail(row, detail, includeContent)
|
||
detailCommands = append(detailCommands, map[string]any{
|
||
"index": len(detailCommands) + 1,
|
||
"title": row["标题"],
|
||
"command": "dws report entry get --report-id " + entry.reportID + " --format json",
|
||
})
|
||
}
|
||
if !includeContent {
|
||
delete(row, "日志内容")
|
||
}
|
||
rows = append(rows, row)
|
||
}
|
||
title := reportReadableListTitle(operation)
|
||
columns := reportListDisplayColumns(operation)
|
||
markdownHeader := reportListMarkdownHeader(columns)
|
||
markdownTable := reportListMarkdownTable(columns, rows)
|
||
display := map[string]any{
|
||
"success": reportListSuccess(body),
|
||
"result": rows,
|
||
"count": len(rows),
|
||
"agentDisplayTitle": title,
|
||
"agentDisplayColumns": columns,
|
||
"agentDisplayRequired": true,
|
||
"agentDisplayRequiredColumns": columns,
|
||
"agentDisplayLinkColumn": "钉钉链接",
|
||
"agentDisplayMarkdownHeader": markdownHeader,
|
||
"agentDisplayMarkdown": markdownTable,
|
||
"agentDisplayMarkdownRequired": true,
|
||
"agentDisplayContentIncluded": includeContent,
|
||
"agentDisplay": map[string]any{
|
||
"type": "table",
|
||
"title": title,
|
||
"columns": columns,
|
||
"required": true,
|
||
"requiredColumns": columns,
|
||
"linkColumn": "钉钉链接",
|
||
"render": "markdown",
|
||
"markdown": markdownTable,
|
||
"markdownRequired": true,
|
||
"contentIncluded": includeContent,
|
||
},
|
||
"agentDisplayInstruction": reportListAgentDisplayInstruction(includeContent, markdownHeader),
|
||
"_internalDetailCommands": detailCommands,
|
||
}
|
||
copyReportListPageField(display, body, "hasMore")
|
||
copyReportListPageField(display, body, "nextCursor")
|
||
copyReportListPageField(display, body, "cursor")
|
||
return display
|
||
}
|
||
|
||
func reportListIncludesContent(operation string) bool {
|
||
return operation == "sent"
|
||
}
|
||
|
||
func reportListAgentDisplayInstruction(includeContent bool, markdownHeader string) string {
|
||
if !includeContent {
|
||
return "CLI 只返回 JSON 数据;已对 result[] 中所有带 reportId 的日志调用 dws report entry get --report-id <reportId> --format json 只补齐日期、标题、发送人、状态和钉钉链接。inbox list 接口侧禁止返回日志正文、完整内容或日志内容摘要;Agent 面向用户时必须直接原样输出 agentDisplayMarkdown 作为 Markdown 表,不要自行重组列、不要改表头、不要把它渲染成 4 列摘要表。表头必须逐字为:" + markdownHeader + "。钉钉链接列是强制列,必须使用 agentDisplayMarkdown/result[].钉钉链接 中的 markdown link;禁止省略、合并、改名。凡用户说列出/找到/查询/搜索/看看日志,默认都要在 final reply 渲染 Markdown 表;只有用户明确表示不关心列表内容、只要原始 JSON、只要数量或只要 ID 时,才可以不渲染表。不要展示 reportId、毫秒时间戳、_internalDetailCommands 或日志正文。"
|
||
}
|
||
return "CLI 只返回 JSON 数据;已对 result[] 中所有带 reportId 的日志调用 dws report entry get --report-id <reportId> --format json 补齐日期、标题、发送人、状态、日志内容和钉钉链接。Agent 面向用户时必须直接原样输出 agentDisplayMarkdown 作为 Markdown 表,不要自行重组列、不要改表头、不要把它渲染成 4 列摘要表。表头必须逐字为:" + markdownHeader + "。钉钉链接列是强制列,必须使用 agentDisplayMarkdown/result[].钉钉链接 中的 markdown link;禁止省略、合并、改名。凡用户说列出/找到/查询/搜索/看看日志,默认都要在 final reply 渲染 Markdown 表;只有用户明确表示不关心日志内容、只要原始 JSON、只要数量或只要 ID 时,才可以不渲染表。不要展示 reportId、毫秒时间戳或 _internalDetailCommands。"
|
||
}
|
||
|
||
func reportListDisplayColumns(operation string) []string {
|
||
if reportListIncludesContent(operation) {
|
||
return []string{"日期", "标题", "发送人", "状态", "日志内容", "钉钉链接"}
|
||
}
|
||
return []string{"日期", "标题", "发送人", "状态", "钉钉链接"}
|
||
}
|
||
|
||
func reportListMarkdownHeader(columns []string) string {
|
||
return "| " + strings.Join(columns, " | ") + " |"
|
||
}
|
||
|
||
func reportListMarkdownTable(columns []string, rows []map[string]string) string {
|
||
var b strings.Builder
|
||
writeMarkdownRow := func(values []string) {
|
||
b.WriteString(reportListMarkdownHeader(values))
|
||
b.WriteString("\n")
|
||
}
|
||
writeMarkdownRow(columns)
|
||
delimiters := make([]string, len(columns))
|
||
for i := range delimiters {
|
||
delimiters[i] = "---"
|
||
}
|
||
writeMarkdownRow(delimiters)
|
||
for _, row := range rows {
|
||
values := make([]string, 0, len(columns))
|
||
for _, column := range columns {
|
||
values = append(values, reportMarkdownCell(row[column]))
|
||
}
|
||
writeMarkdownRow(values)
|
||
}
|
||
return strings.TrimRight(b.String(), "\n")
|
||
}
|
||
|
||
func reportMarkdownCell(s string) string {
|
||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||
s = strings.ReplaceAll(s, "\r", "\n")
|
||
s = strings.Join(strings.Fields(s), " ")
|
||
return strings.ReplaceAll(s, "|", "|")
|
||
}
|
||
|
||
type reportReadableEntry struct {
|
||
row map[string]string
|
||
reportID string
|
||
createdAtMillis int64
|
||
}
|
||
|
||
func makeReportReadableEntries(items []map[string]any) []reportReadableEntry {
|
||
entries := make([]reportReadableEntry, 0, len(items))
|
||
for _, item := range items {
|
||
entries = append(entries, reportReadableEntry{
|
||
row: reportReadableRow(item),
|
||
reportID: reportIDFromMap(item),
|
||
createdAtMillis: reportReadableCreateTimeMillis(item),
|
||
})
|
||
}
|
||
return entries
|
||
}
|
||
|
||
func sortReportReadableEntries(entries []reportReadableEntry) {
|
||
sort.SliceStable(entries, func(i, j int) bool {
|
||
left := entries[i]
|
||
right := entries[j]
|
||
if left.createdAtMillis != right.createdAtMillis {
|
||
if left.createdAtMillis == 0 {
|
||
return false
|
||
}
|
||
if right.createdAtMillis == 0 {
|
||
return true
|
||
}
|
||
return left.createdAtMillis > right.createdAtMillis
|
||
}
|
||
return left.row["日期"] > right.row["日期"]
|
||
})
|
||
}
|
||
|
||
func reportReadableListTitle(operation string) string {
|
||
switch operation {
|
||
case "sent":
|
||
return "我创建的日志列表"
|
||
default:
|
||
return "收到的日志列表"
|
||
}
|
||
}
|
||
|
||
func reportListSuccess(body map[string]any) any {
|
||
if v, ok := body["success"]; ok {
|
||
return v
|
||
}
|
||
return true
|
||
}
|
||
|
||
func copyReportListPageField(dst, src map[string]any, key string) {
|
||
if v, ok := src[key]; ok {
|
||
dst[key] = v
|
||
return
|
||
}
|
||
if result, ok := src["result"].(map[string]any); ok {
|
||
if v, ok := result[key]; ok {
|
||
dst[key] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
type reportListDetail struct {
|
||
date string
|
||
title string
|
||
sender string
|
||
status string
|
||
content string
|
||
markdownLink string
|
||
}
|
||
|
||
func mergeReportListDetail(row map[string]string, detail reportListDetail, includeContent bool) {
|
||
if detail.date != "" {
|
||
row["日期"] = detail.date
|
||
}
|
||
if detail.title != "" {
|
||
row["标题"] = detail.title
|
||
}
|
||
if detail.sender != "" {
|
||
row["发送人"] = detail.sender
|
||
}
|
||
if detail.status != "" {
|
||
row["状态"] = detail.status
|
||
}
|
||
if includeContent {
|
||
row["日志内容"] = detail.content
|
||
}
|
||
row["钉钉链接"] = detail.markdownLink
|
||
}
|
||
|
||
func reportDetailForListRow(ctx context.Context, reportID string, includeContent bool) reportListDetail {
|
||
detailText, err := callMCPToolReturnTextOnServer(ctx, "report", "get_report_entry_details", map[string]any{
|
||
"report_id": reportID,
|
||
})
|
||
if err != nil {
|
||
return reportListDetail{markdownLink: "查看详情"}
|
||
}
|
||
var detail any
|
||
if err := json.Unmarshal([]byte(detailText), &detail); err != nil {
|
||
return reportListDetail{markdownLink: "查看详情"}
|
||
}
|
||
markdownLink := reportMarkdownLinkFromResponse(detail)
|
||
if markdownLink == "" {
|
||
markdownLink = "查看详情"
|
||
}
|
||
result := reportListDetail{
|
||
date: reportReadableDetailDate(detail),
|
||
title: reportReadableDetailTitle(detail),
|
||
sender: reportReadableDetailSender(detail),
|
||
status: reportReadableDetailStatus(detail),
|
||
markdownLink: markdownLink,
|
||
}
|
||
if includeContent {
|
||
result.content = reportReadableDetailContent(detail)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func reportReadableDetailDate(detail any) string {
|
||
for _, m := range reportResponseMaps(detail) {
|
||
if date := reportReadableCreateTime(m); date != "" {
|
||
return date
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportReadableDetailTitle(detail any) string {
|
||
for _, m := range reportResponseMaps(detail) {
|
||
if title := firstReportString(m, "report_name", "reportName", "title", "summary", "report_template_name", "templateName"); title != "" {
|
||
return reportCompactDisplayText(title, 120)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportReadableDetailSender(detail any) string {
|
||
for _, m := range reportResponseMaps(detail) {
|
||
if sender := firstReportString(m, "creatorName", "senderName", "userName", "creator", "sender"); sender != "" {
|
||
return reportCompactDisplayText(sender, 80)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportReadableDetailStatus(detail any) string {
|
||
for _, m := range reportResponseMaps(detail) {
|
||
if status := reportReadableStatus(m); status != "" {
|
||
return status
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportMarkdownLinkFromResponse(detail any) string {
|
||
if link := reportStringFromResponse(detail, "dingtalkOpenMarkdownLink"); link != "" {
|
||
return link
|
||
}
|
||
if url := reportURLFromResponse(detail); url != "" {
|
||
return reportDingtalkMarkdownLink(url)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportStringFromResponse(detail any, key string) string {
|
||
for _, m := range reportResponseMaps(detail) {
|
||
if s, ok := m[key].(string); ok {
|
||
if s = strings.TrimSpace(s); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportReadableDetailContent(detail any) string {
|
||
for _, m := range reportResponseMaps(detail) {
|
||
for _, key := range []string{"report_content", "reportContent", "report_contents", "reportContents", "contents", "contentList"} {
|
||
if content := reportReadableContentFromValue(m[key]); content != "" {
|
||
return reportCompactDisplayText(content, 600)
|
||
}
|
||
}
|
||
}
|
||
for _, m := range reportResponseMaps(detail) {
|
||
if content := firstReportString(m, "content", "summary", "text", "plainText", "reportContent"); content != "" {
|
||
return reportCompactDisplayText(content, 600)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportResponseMaps(detail any) []map[string]any {
|
||
body, ok := detail.(map[string]any)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
maps := []map[string]any{body}
|
||
if result, ok := body["result"].(map[string]any); ok {
|
||
maps = append([]map[string]any{result}, maps...)
|
||
}
|
||
return maps
|
||
}
|
||
|
||
func reportReadableContentFromValue(v any) string {
|
||
switch x := v.(type) {
|
||
case []any:
|
||
parts := make([]string, 0, len(x))
|
||
for _, item := range x {
|
||
if s := reportReadableContentFromValue(item); s != "" {
|
||
parts = append(parts, s)
|
||
}
|
||
}
|
||
return strings.Join(parts, ";")
|
||
case map[string]any:
|
||
label := firstReportString(x, "key", "field_name", "fieldName", "name", "title")
|
||
value := reportFirstDisplayValue(x, "value", "content", "text", "plainText", "markdown", "richTextValue")
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
if label != "" {
|
||
return label + ":" + value
|
||
}
|
||
return value
|
||
case string:
|
||
return strings.TrimSpace(x)
|
||
default:
|
||
return reportDisplayStringFromValue(x)
|
||
}
|
||
}
|
||
|
||
func reportFirstDisplayValue(m map[string]any, keys ...string) string {
|
||
for _, key := range keys {
|
||
if v, ok := m[key]; ok {
|
||
if s := reportDisplayStringFromValue(v); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportDisplayStringFromValue(v any) string {
|
||
switch x := v.(type) {
|
||
case string:
|
||
return strings.TrimSpace(x)
|
||
case float64:
|
||
return strconv.FormatFloat(x, 'f', -1, 64)
|
||
case bool:
|
||
if x {
|
||
return "true"
|
||
}
|
||
return "false"
|
||
case []any:
|
||
parts := make([]string, 0, len(x))
|
||
for _, item := range x {
|
||
if s := reportDisplayStringFromValue(item); s != "" {
|
||
parts = append(parts, s)
|
||
}
|
||
}
|
||
return strings.Join(parts, " ")
|
||
case map[string]any:
|
||
return reportFirstDisplayValue(x, "value", "text", "content", "plainText", "name", "title")
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func reportCompactDisplayText(s string, maxRunes int) string {
|
||
s = strings.ReplaceAll(s, "|", "|")
|
||
s = strings.Join(strings.Fields(s), " ")
|
||
if maxRunes <= 0 {
|
||
return s
|
||
}
|
||
runes := []rune(s)
|
||
if len(runes) <= maxRunes {
|
||
return s
|
||
}
|
||
return string(runes[:maxRunes]) + "..."
|
||
}
|
||
|
||
func findReportListItems(root map[string]any) ([]map[string]any, bool) {
|
||
for _, key := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList", "result"} {
|
||
value, ok := root[key]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if items, found := reportItemsFromValue(value); found {
|
||
return items, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func reportItemsFromValue(v any) ([]map[string]any, bool) {
|
||
switch x := v.(type) {
|
||
case []any:
|
||
items := make([]map[string]any, 0, len(x))
|
||
for _, raw := range x {
|
||
item, ok := raw.(map[string]any)
|
||
if !ok || reportIDFromMap(item) == "" {
|
||
continue
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
return items, true
|
||
case map[string]any:
|
||
for _, key := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList"} {
|
||
value, ok := x[key]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if items, found := reportItemsFromValue(value); found {
|
||
return items, true
|
||
}
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func reportReadableRow(item map[string]any) map[string]string {
|
||
sender := firstReportString(item, "creatorName", "senderName", "userName", "creator", "sender")
|
||
title := firstReportString(item, "report_name", "reportName", "title", "summary", "report_template_name", "templateName")
|
||
if title == "" && sender != "" {
|
||
title = sender + "的日志"
|
||
}
|
||
if title == "" {
|
||
title = "日志"
|
||
}
|
||
createdAt := reportReadableCreateTime(item)
|
||
return map[string]string{
|
||
"日期": createdAt,
|
||
"标题": title,
|
||
"发送人": sender,
|
||
"状态": reportReadableStatus(item),
|
||
}
|
||
}
|
||
|
||
func firstReportString(item map[string]any, keys ...string) string {
|
||
for _, key := range keys {
|
||
if s, ok := item[key].(string); ok {
|
||
if s = strings.TrimSpace(s); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportReadableCreateTime(item map[string]any) string {
|
||
if ms := reportReadableCreateTimeMillis(item); ms > 0 {
|
||
return reportMillisToLocalString(ms)
|
||
}
|
||
for _, key := range []string{"createTime", "gmtCreate", "sendTime", "time", "modifiedTime"} {
|
||
if s := reportTimeValueToString(item[key]); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportReadableCreateTimeMillis(item map[string]any) int64 {
|
||
for _, key := range []string{"createTime", "gmtCreate", "sendTime", "time", "modifiedTime"} {
|
||
if ms := reportTimeValueToMillis(item[key]); ms > 0 {
|
||
return ms
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func reportTimeValueToString(v any) string {
|
||
if ms := reportTimeValueToMillis(v); ms > 0 {
|
||
return reportMillisToLocalString(ms)
|
||
}
|
||
switch x := v.(type) {
|
||
case string:
|
||
x = strings.TrimSpace(x)
|
||
if x == "" {
|
||
return ""
|
||
}
|
||
if t, err := time.Parse(time.RFC3339, x); err == nil {
|
||
return t.Local().Format("2006-01-02 15:04")
|
||
}
|
||
return x
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func reportTimeValueToMillis(v any) int64 {
|
||
switch x := v.(type) {
|
||
case float64:
|
||
return normalizeReportTimestamp(int64(x))
|
||
case int64:
|
||
return normalizeReportTimestamp(x)
|
||
case int:
|
||
return normalizeReportTimestamp(int64(x))
|
||
case json.Number:
|
||
n, err := x.Int64()
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return normalizeReportTimestamp(n)
|
||
case string:
|
||
x = strings.TrimSpace(x)
|
||
if x == "" {
|
||
return 0
|
||
}
|
||
if n, err := strconv.ParseInt(x, 10, 64); err == nil {
|
||
return normalizeReportTimestamp(n)
|
||
}
|
||
if t, err := time.Parse(time.RFC3339, x); err == nil {
|
||
return t.Local().UnixMilli()
|
||
}
|
||
if t, err := time.ParseInLocation("2006-01-02 15:04", x, time.Local); err == nil {
|
||
return t.UnixMilli()
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func normalizeReportTimestamp(ts int64) int64 {
|
||
if ts <= 0 {
|
||
return 0
|
||
}
|
||
if ts < 100000000000 {
|
||
ts *= 1000
|
||
}
|
||
return ts
|
||
}
|
||
|
||
func reportMillisToLocalString(ms int64) string {
|
||
ms = normalizeReportTimestamp(ms)
|
||
if ms == 0 {
|
||
return ""
|
||
}
|
||
return time.UnixMilli(ms).Local().Format("2006-01-02 15:04")
|
||
}
|
||
|
||
func reportReadableStatus(item map[string]any) string {
|
||
for _, key := range []string{"readStatus", "isRead", "hasRead", "read"} {
|
||
if status := reportStatusValueToString(item[key]); status != "" {
|
||
return status
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportStatusValueToString(v any) string {
|
||
switch x := v.(type) {
|
||
case bool:
|
||
if x {
|
||
return "已读"
|
||
}
|
||
return "未读"
|
||
case string:
|
||
s := strings.TrimSpace(strings.ToLower(x))
|
||
switch s {
|
||
case "true", "read", "已读", "1":
|
||
return "已读"
|
||
case "false", "unread", "未读", "0":
|
||
return "未读"
|
||
default:
|
||
return strings.TrimSpace(x)
|
||
}
|
||
case float64:
|
||
if x == 1 {
|
||
return "已读"
|
||
}
|
||
if x == 0 {
|
||
return "未读"
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func withReportDispatchHint(operation string, err error) error {
|
||
if err == nil {
|
||
if deps.Caller.Format() == "json" {
|
||
return nil
|
||
}
|
||
if hint := reportDispatchHint(operation, ""); hint != "" {
|
||
deps.Out.PrintDim(hint)
|
||
}
|
||
return nil
|
||
}
|
||
cliErr, ok := err.(*CLIError)
|
||
if !ok {
|
||
return err
|
||
}
|
||
extraHint := reportDispatchHint(operation, cliErr.Message)
|
||
if extraHint == "" {
|
||
return err
|
||
}
|
||
if cliErr.Suggestion == "" {
|
||
cliErr.Suggestion = extraHint
|
||
return cliErr
|
||
}
|
||
cliErr.Suggestion = cliErr.Suggestion + "\n " + extraHint
|
||
return cliErr
|
||
}
|
||
|
||
func reportDispatchHint(operation, message string) string {
|
||
normalizedOp := strings.TrimSpace(operation)
|
||
lowerMsg := strings.ToLower(strings.TrimSpace(message))
|
||
success := lowerMsg == ""
|
||
|
||
switch normalizedOp {
|
||
case "template-list":
|
||
if success {
|
||
return "可复用指令:\n " + reportDispatchTemplateSuccessHint
|
||
}
|
||
case "template-detail":
|
||
if success {
|
||
return "可复用指令:\n " + reportDispatchTemplateDetailHint
|
||
}
|
||
case "create":
|
||
if success {
|
||
return ""
|
||
}
|
||
case "detail":
|
||
if success {
|
||
return "可复用指令:\n " + reportDispatchDetailHint
|
||
}
|
||
case "stats":
|
||
if success {
|
||
return "可复用指令:\n " + reportDispatchStatsHint
|
||
}
|
||
case "list":
|
||
if success {
|
||
return "可复用指令:\n " + reportDispatchListHint
|
||
}
|
||
case "sent":
|
||
if success {
|
||
return "可复用指令:\n " + reportDispatchOutboxListHint
|
||
}
|
||
}
|
||
switch normalizedOp {
|
||
case "create":
|
||
if strings.Contains(lowerMsg, "参数错误") || strings.Contains(lowerMsg, "param error") || strings.Contains(lowerMsg, "param_error") {
|
||
return "可复用指令:\n " + reportDispatchCreateHint
|
||
}
|
||
}
|
||
switch normalizedOp {
|
||
case "detail":
|
||
return "可复用指令:\n " + reportDispatchDetailHint
|
||
case "stats":
|
||
return "可复用指令:\n " + reportDispatchStatsHint
|
||
case "list":
|
||
return "可复用指令:\n " + reportDispatchListHint
|
||
case "sent":
|
||
return "可复用指令:\n " + reportDispatchOutboxListHint
|
||
case "template-list":
|
||
return "可复用指令:\n " + reportDispatchAuthFailureHint
|
||
case "template-detail":
|
||
return "可复用指令:\n " + reportDispatchTemplateListHint
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func callReportCreateWithDetailURL(toolArgs map[string]any) error {
|
||
if deps.Caller.DryRun() {
|
||
return withReportDispatchHint("create", callMCPTool("create_report", toolArgs))
|
||
}
|
||
|
||
ctx := context.Background()
|
||
createText, err := callMCPToolReturnTextOnServer(ctx, "report", "create_report", toolArgs)
|
||
if err != nil {
|
||
return withReportDispatchHint("create", err)
|
||
}
|
||
return printReportCreateWithDetailURL(ctx, createText)
|
||
}
|
||
|
||
func printReportCreateWithDetailURL(ctx context.Context, createText string) error {
|
||
var parsed any
|
||
if err := json.Unmarshal([]byte(createText), &parsed); err != nil {
|
||
deps.Out.PrintRaw(createText)
|
||
return nil
|
||
}
|
||
enriched := enrichReportCreateWithDetailURL(ctx, parsed)
|
||
return deps.Out.PrintJSONUnescaped(enriched)
|
||
}
|
||
|
||
func enrichReportCreateWithDetailURL(ctx context.Context, parsed any) any {
|
||
body, ok := parsed.(map[string]any)
|
||
if !ok {
|
||
return parsed
|
||
}
|
||
if url := reportURLFromResponse(body); url != "" {
|
||
attachReportURL(body, url)
|
||
return body
|
||
}
|
||
reportID := reportIDFromCreateResponse(body)
|
||
if reportID == "" {
|
||
body["urlLookupError"] = "create response did not include reportId"
|
||
return body
|
||
}
|
||
|
||
detailText, err := callMCPToolReturnTextOnServer(ctx, "report", "get_report_entry_details", map[string]any{
|
||
"report_id": reportID,
|
||
})
|
||
if err != nil {
|
||
body["urlLookupError"] = err.Error()
|
||
return body
|
||
}
|
||
var detail any
|
||
if err := json.Unmarshal([]byte(detailText), &detail); err != nil {
|
||
body["urlLookupError"] = fmt.Sprintf("detail response JSON parse failed: %v", err)
|
||
return body
|
||
}
|
||
url := reportURLFromResponse(detail)
|
||
if url == "" {
|
||
body["urlLookupError"] = "detail response did not include result.url"
|
||
return body
|
||
}
|
||
attachReportURL(body, url)
|
||
return body
|
||
}
|
||
|
||
func reportIDFromCreateResponse(body map[string]any) string {
|
||
if id := reportIDFromMap(body); id != "" {
|
||
return id
|
||
}
|
||
if result, ok := body["result"].(map[string]any); ok {
|
||
if id := reportIDFromMap(result); id != "" {
|
||
return id
|
||
}
|
||
}
|
||
if result, ok := body["result"].(string); ok {
|
||
return strings.TrimSpace(result)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportIDFromMap(m map[string]any) string {
|
||
for _, key := range []string{"reportId", "reportID", "report_id", "report_Id", "report-id"} {
|
||
if id, ok := m[key].(string); ok {
|
||
if id = strings.TrimSpace(id); id != "" {
|
||
return id
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportURLFromResponse(detail any) string {
|
||
body, ok := detail.(map[string]any)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
if result, ok := body["result"].(map[string]any); ok {
|
||
for _, key := range []string{"url", "dingtalkOpenUrl"} {
|
||
if url, ok := result[key].(string); ok {
|
||
if url = strings.TrimSpace(url); url != "" {
|
||
return url
|
||
}
|
||
}
|
||
}
|
||
if link, ok := result["dingtalkOpenLink"].(map[string]any); ok {
|
||
if url, ok := link["url"].(string); ok {
|
||
if url = strings.TrimSpace(url); url != "" {
|
||
return url
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for _, key := range []string{"url", "dingtalkOpenUrl"} {
|
||
if url, ok := body[key].(string); ok {
|
||
if url = strings.TrimSpace(url); url != "" {
|
||
return url
|
||
}
|
||
}
|
||
}
|
||
if link, ok := body["dingtalkOpenLink"].(map[string]any); ok {
|
||
if url, ok := link["url"].(string); ok {
|
||
return strings.TrimSpace(url)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func attachReportURL(body map[string]any, url string) {
|
||
body["url"] = url
|
||
body["dingtalkOpenUrl"] = url
|
||
body["dingtalkOpenLinkText"] = reportDingtalkOpenLinkText
|
||
body["dingtalkOpenMarkdownLink"] = reportDingtalkMarkdownLink(url)
|
||
body["dingtalkOpenLink"] = reportDingtalkOpenLink(url)
|
||
if result, ok := body["result"].(map[string]any); ok {
|
||
result["url"] = url
|
||
result["dingtalkOpenUrl"] = url
|
||
result["dingtalkOpenLinkText"] = reportDingtalkOpenLinkText
|
||
result["dingtalkOpenMarkdownLink"] = reportDingtalkMarkdownLink(url)
|
||
result["dingtalkOpenLink"] = reportDingtalkOpenLink(url)
|
||
}
|
||
}
|
||
|
||
func reportDingtalkMarkdownLink(url string) string {
|
||
return fmt.Sprintf("[%s](%s)", reportDingtalkOpenLinkText, url)
|
||
}
|
||
|
||
func reportDingtalkOpenLink(url string) map[string]any {
|
||
return map[string]any{
|
||
"title": reportDingtalkOpenLinkText,
|
||
"url": url,
|
||
"description": reportDingtalkOpenLinkDescription,
|
||
}
|
||
}
|
||
|
||
// resolveReportContentsFromFlags reads contents JSON from one of three sources,
|
||
// in priority order:
|
||
//
|
||
// --contents-file <path> read from file (UTF-8)
|
||
// --contents - read from stdin
|
||
// --contents "<json>" literal value
|
||
//
|
||
// Returns INPUT_FILE_NOT_FOUND when --contents-file path does not exist; the
|
||
// caller is responsible for parsing the returned string as JSON.
|
||
func resolveReportContentsFromFlags(cmd *cobra.Command) (string, error) {
|
||
filePath, _ := cmd.Flags().GetString("contents-file")
|
||
if filePath != "" {
|
||
safePath, err := resolveSafeReportContentsFilePath(filePath)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return readReportContentsFile(safePath)
|
||
}
|
||
raw, _ := cmd.Flags().GetString("contents")
|
||
if raw == "-" {
|
||
return readReportContentsStdin(cmd.InOrStdin())
|
||
}
|
||
return raw, nil
|
||
}
|
||
|
||
func readReportContentsFile(filePath string) (string, error) {
|
||
file, err := reportOpenFile(filePath)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return "", &CLIError{
|
||
Code: CodeFileNotFound,
|
||
Message: fmt.Sprintf("--contents-file: file not found: %s", filePath),
|
||
Suggestion: "确认路径存在,且路径位于当前工作目录内",
|
||
Operation: "report.create",
|
||
Cause: err,
|
||
}
|
||
}
|
||
return "", &CLIError{
|
||
Code: CodeFileNotFound,
|
||
Message: fmt.Sprintf("--contents-file: cannot read %s: %v", filePath, err),
|
||
Operation: "report.create",
|
||
Cause: err,
|
||
}
|
||
}
|
||
defer file.Close()
|
||
return readReportContentsLimited(file, fmt.Sprintf("--contents-file %s", filePath))
|
||
}
|
||
|
||
func readReportContentsStdin(reader io.Reader) (string, error) {
|
||
contents, err := readReportContentsLimited(reader, "--contents -")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return contents, nil
|
||
}
|
||
|
||
func readReportContentsLimited(reader io.Reader, source string) (string, error) {
|
||
data, err := io.ReadAll(io.LimitReader(reader, int64(reportContentsMaxBytes)+1))
|
||
if err != nil {
|
||
return "", &CLIError{
|
||
Code: CodeInvalidJSON,
|
||
Message: fmt.Sprintf("%s: read failed: %v", source, err),
|
||
Operation: "report.create",
|
||
Cause: err,
|
||
}
|
||
}
|
||
if len(data) > reportContentsMaxBytes {
|
||
return "", reportContentsTooLargeError(source)
|
||
}
|
||
return string(data), nil
|
||
}
|
||
|
||
func reportContentsTooLargeError(source string) error {
|
||
return &CLIError{
|
||
Code: CodeInputTooLarge,
|
||
Message: fmt.Sprintf("%s: contents exceed maximum size of 10MB", source),
|
||
Suggestion: "contents JSON 超过 10MB 限制,不支持分批次提交。请精简内容或拆分为多个独立的日志提交",
|
||
Operation: "report.create",
|
||
}
|
||
}
|
||
|
||
func resolveSafeReportContentsFilePath(filePath string) (string, error) {
|
||
cleanPath := filepath.Clean(filePath)
|
||
if cleanPath == "." || cleanPath == "-" {
|
||
return "", reportContentsFilePathError(filePath, "must be a file path under the current working directory")
|
||
}
|
||
if pathEscapesUpward(cleanPath) {
|
||
return "", reportContentsFilePathError(filePath, "parent-directory traversal is not allowed")
|
||
}
|
||
|
||
absPath, err := reportAbsPath(cleanPath)
|
||
if err != nil {
|
||
return "", reportContentsFilePathError(filePath, err.Error())
|
||
}
|
||
cwd, err := reportGetwd()
|
||
if err != nil {
|
||
return "", &CLIError{
|
||
Code: CodeFileNotFound,
|
||
Message: fmt.Sprintf("--contents-file: cannot determine current working directory: %v", err),
|
||
Operation: "report.create",
|
||
Cause: err,
|
||
}
|
||
}
|
||
cwdAbs, err := reportAbsPath(cwd)
|
||
if err != nil {
|
||
return "", reportContentsFilePathError(filePath, err.Error())
|
||
}
|
||
if !pathWithinRoot(cwdAbs, absPath) {
|
||
return "", reportContentsFilePathError(filePath, "path must stay under the current working directory")
|
||
}
|
||
rootPath, err := reportEvalSymlinks(cwdAbs)
|
||
if err != nil {
|
||
return "", &CLIError{
|
||
Code: CodeFileNotFound,
|
||
Message: fmt.Sprintf("--contents-file: cannot resolve current working directory: %v", err),
|
||
Operation: "report.create",
|
||
Cause: err,
|
||
}
|
||
}
|
||
info, err := reportStat(absPath)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return "", &CLIError{
|
||
Code: CodeFileNotFound,
|
||
Message: fmt.Sprintf("--contents-file: file not found: %s", absPath),
|
||
Suggestion: "确认路径存在,且路径位于当前工作目录内",
|
||
Operation: "report.create",
|
||
Cause: err,
|
||
}
|
||
}
|
||
return "", &CLIError{
|
||
Code: CodeFileNotFound,
|
||
Message: fmt.Sprintf("--contents-file: cannot read %s: %v", absPath, err),
|
||
Operation: "report.create",
|
||
Cause: err,
|
||
}
|
||
}
|
||
if info.IsDir() {
|
||
return "", reportContentsFilePathError(filePath, "must point to a file, not a directory")
|
||
}
|
||
realPath, err := reportEvalSymlinks(absPath)
|
||
if err != nil {
|
||
return "", &CLIError{
|
||
Code: CodeFileNotFound,
|
||
Message: fmt.Sprintf("--contents-file: cannot resolve %s: %v", absPath, err),
|
||
Operation: "report.create",
|
||
Cause: err,
|
||
}
|
||
}
|
||
if !pathWithinRoot(rootPath, realPath) {
|
||
return "", reportContentsFilePathError(filePath, "resolved path must stay under the current working directory")
|
||
}
|
||
return realPath, nil
|
||
}
|
||
|
||
func pathEscapesUpward(cleanPath string) bool {
|
||
return cleanPath == ".." || strings.HasPrefix(cleanPath, ".."+string(os.PathSeparator))
|
||
}
|
||
|
||
func pathWithinRoot(rootPath, targetPath string) bool {
|
||
rel, err := reportRelPath(rootPath, targetPath)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && !filepath.IsAbs(rel))
|
||
}
|
||
|
||
func reportContentsFilePathError(filePath, reason string) error {
|
||
return &CLIError{
|
||
Code: CodeInvalidPath,
|
||
Message: fmt.Sprintf("--contents-file: invalid path %q: %s", filePath, reason),
|
||
Suggestion: "使用当前工作目录下的 JSON 文件;不要使用 ../ 路径或指向目录外的符号链接",
|
||
Operation: "report.create",
|
||
}
|
||
}
|
||
|
||
func validateAndNormalizeReportContents(contents []map[string]any) error {
|
||
if len(contents) == 0 {
|
||
return reportContentsValidationError("contents must contain at least one item")
|
||
}
|
||
for i, item := range contents {
|
||
if item == nil {
|
||
return reportContentsValidationError(fmt.Sprintf("contents[%d] must be an object", i))
|
||
}
|
||
key, err := reportRequiredString(item, i, "key")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
sort, err := normalizeReportSort(item, i)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
content, err := reportRequiredString(item, i, "content")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, ok := item["type"]; !ok {
|
||
return reportContentsValidationError(fmt.Sprintf("contents[%d].type is required", i))
|
||
}
|
||
fieldType, err := normalizeReportFieldType(item["type"], i)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, ok := item["contentType"]; !ok {
|
||
return reportContentsValidationError(fmt.Sprintf("contents[%d].contentType is required", i))
|
||
}
|
||
contentType, err := normalizeReportContentType(item["contentType"], fieldType, i)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
item["key"] = key
|
||
item["sort"] = sort
|
||
item["content"] = content
|
||
item["type"] = fieldType
|
||
item["contentType"] = contentType
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func reportRequiredString(item map[string]any, index int, field string) (string, error) {
|
||
v, ok := item[field]
|
||
if !ok {
|
||
return "", reportContentsValidationError(fmt.Sprintf("contents[%d].%s is required", index, field))
|
||
}
|
||
s, ok := v.(string)
|
||
if !ok {
|
||
return "", reportContentsValidationError(fmt.Sprintf("contents[%d].%s must be a string", index, field))
|
||
}
|
||
if field == "content" {
|
||
return s, nil
|
||
}
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return "", reportContentsValidationError(fmt.Sprintf("contents[%d].%s cannot be empty", index, field))
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
func normalizeReportSort(item map[string]any, index int) (string, error) {
|
||
v, ok := item["sort"]
|
||
if !ok {
|
||
return "", reportContentsValidationError(fmt.Sprintf("contents[%d].sort is required", index))
|
||
}
|
||
raw, ok := reportScalarToString(v)
|
||
if !ok {
|
||
return "", reportContentsValidationError(fmt.Sprintf("contents[%d].sort must be an integer or integer string", index))
|
||
}
|
||
sort := strings.TrimSpace(raw)
|
||
if sort == "" {
|
||
return "", reportContentsValidationError(fmt.Sprintf("contents[%d].sort cannot be empty", index))
|
||
}
|
||
if _, err := strconv.Atoi(sort); err != nil {
|
||
return "", reportContentsValidationError(fmt.Sprintf("contents[%d].sort=%q is invalid; use template get field_sort", index, raw))
|
||
}
|
||
return sort, nil
|
||
}
|
||
|
||
func normalizeReportFieldType(v any, index int) (string, error) {
|
||
raw, ok := reportScalarToString(v)
|
||
if !ok {
|
||
return "", reportContentsValidationError(fmt.Sprintf("contents[%d].type must be one of 1/2/3/5/7/8/9", index))
|
||
}
|
||
t := strings.ToLower(strings.TrimSpace(raw))
|
||
aliases := map[string]string{
|
||
"1": "1", "text": "1", "markdown": "1", "文本": "1",
|
||
"2": "2", "number": "2", "num": "2", "数字": "2",
|
||
"3": "3", "single": "3", "single_select": "3", "radio": "3", "单选": "3",
|
||
"5": "5", "date": "5", "日期": "5",
|
||
"7": "7", "multi": "7", "multi_select": "7", "checkbox": "7", "多选": "7",
|
||
"8": "8", "image": "8", "picture": "8", "图片": "8",
|
||
"9": "9", "attachment": "9", "file": "9", "附件": "9",
|
||
}
|
||
if normalized, ok := aliases[t]; ok {
|
||
return normalized, nil
|
||
}
|
||
return "", reportContentsValidationError(fmt.Sprintf("contents[%d].type=%q is invalid; use template get field_type, such as \"1\" for text", index, raw))
|
||
}
|
||
|
||
func normalizeReportContentType(v any, fieldType string, index int) (string, error) {
|
||
raw, ok := reportScalarToString(v)
|
||
if !ok {
|
||
return "", reportContentsValidationError(fmt.Sprintf("contents[%d].contentType must be a string", index))
|
||
}
|
||
ct := strings.ToLower(strings.TrimSpace(raw))
|
||
if fieldType == "1" {
|
||
if ct == "markdown" || ct == "text" {
|
||
return "markdown", nil
|
||
}
|
||
return "", reportContentsValidationError(fmt.Sprintf("contents[%d].contentType=%q is invalid for type=1; use \"markdown\"", index, raw))
|
||
}
|
||
if ct == "origin" || ct == "raw" {
|
||
return "origin", nil
|
||
}
|
||
return "", reportContentsValidationError(fmt.Sprintf("contents[%d].contentType=%q is invalid for type=%s; use \"origin\"", index, raw, fieldType))
|
||
}
|
||
|
||
func reportScalarToString(v any) (string, bool) {
|
||
switch x := v.(type) {
|
||
case string:
|
||
return x, true
|
||
case float64:
|
||
if x != float64(int64(x)) {
|
||
return "", false
|
||
}
|
||
return strconv.FormatInt(int64(x), 10), true
|
||
case float32:
|
||
if x != float32(int64(x)) {
|
||
return "", false
|
||
}
|
||
return strconv.FormatInt(int64(x), 10), true
|
||
case int:
|
||
return strconv.Itoa(x), true
|
||
case int64:
|
||
return strconv.FormatInt(x, 10), true
|
||
case int32:
|
||
return strconv.FormatInt(int64(x), 10), true
|
||
case json.Number:
|
||
if _, err := strconv.Atoi(x.String()); err != nil {
|
||
return "", false
|
||
}
|
||
return x.String(), true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
func reportContentsValidationError(message string) error {
|
||
return &CLIError{
|
||
Code: CodeInvalidJSON,
|
||
Message: message,
|
||
Suggestion: "contents 每项必须含 key/sort/content/contentType/type;key/sort/type 来自 report template get,type=1 时 contentType=markdown,其余类型用 origin",
|
||
Operation: "report.create",
|
||
}
|
||
}
|