Files
dingtalk-workspace-cli/internal/helpers/report_readable.go
539d10f80f feat: 合并 pre-mcp-discovery 分支能力到 main(服务发现保持生产) (#391)
将 test/pre-mcp-discovery 分支上多位同学的能力改动整体合入 main,
服务发现端点保持生产 https://mcp.dingtalk.com,未携带分支上的预发改动。

主要内容:
- 修雨域(chat / report / todo / contact / mail)对齐 wukong 基线 (#355)
- DWS 内容生产能力增强 (#387)、文档导出与 OA 对齐 (#362 #388 #390)
- envelope 注册 cli.Aliases 为 cobra aliases、命令结构与 JSON 解析优化
- skills 多包同步 wukong 对齐内容(attendance/calendar/minutes/oa/sheet 等)
- product/oa 进一步对齐 wukong

排除项:未携带 566b4e1(test: point service discovery to pre-mcp.dingtalk.com);
以下 4 个常量保持生产:skill_command.go / auth/endpoints.go / cli/loader.go / market/registry.go。

Co-authored-by: john <32427341+wqyenjoy@users.noreply.github.com>
Co-authored-by: wxianfeng <wang.fl1429@gmail.com>
2026-06-02 21:04:12 +08:00

641 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package helpers — readable-output enrichment for `report inbox list` and
// `report outbox list`.
//
// What this file adds:
//
// - EnrichReportListContent: pure function that walks an MCP `content`
// response, finds the report items list, and overlays five extra fields
// the agent layer relies on:
// success
// count
// agentDisplayContentIncluded (bool — inbox false, outbox true)
// agentDisplayColumns ([]string — wukong-aligned column set)
// agentDisplayMarkdown (string — markdown table)
// The function never mutates the input map in-place; it returns a fresh
// map so callers (including tests) can compare before/after safely.
//
// - AttachReportListReadableEnrichment: post-merge hook that finds the
// dynamic-built `report inbox list` / `report outbox list` leaves in the
// merged command tree and wraps their RunE to apply EnrichReportListContent
// to the JSON payload before it is written to stdout. Behaviour for other
// formats (--format raw / table / csv etc.) is preserved verbatim —
// enrichment only fires when the output is JSON.
//
// Why a post-merge hook (mirroring AttachReportLegacyInboxAlias):
//
// the envelope already publishes `report inbox` and `report outbox` as
// groups with a `list` leaf each; the open-source CLI cannot add a same-
// named helper leaf (MergeHardcodedLeaves would reject it as a shape
// mismatch with the envelope). Wrapping the existing leaf's RunE keeps the
// envelope as the single source of truth for flags/schema while letting us
// layer wukong-equivalent enrichment on top.
package helpers
import (
"bytes"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
// ── public constants (mirrors wukong/products/report.go) ────────────────
// reportInboxColumns / reportOutboxColumns must stay byte-identical to the
// REPORT_*_COLUMNS constants in dws-wukong/auto-test/cli_to_mcp/testcases/
// report/test_90_report_param_regression.py — drift breaks the param-
// regression assertions. The only difference between the two is the "日志内容"
// column, which inbox elides (privacy: never echo inbox bodies back as
// agent-visible content) and outbox keeps (the user is the author).
var (
reportInboxColumns = []string{"日期", "标题", "发送人", "状态", "钉钉链接"}
reportOutboxColumns = []string{"日期", "标题", "发送人", "状态", "日志内容", "钉钉链接"}
)
// reportDingtalkLinkText is the visible label used inside the markdown link
// in the "钉钉链接" column. The wukong upstream also uses this literal; tests
// (REPORT_LINK_MARKER) match on the prefix "[在钉钉中查看日志](" so any change
// here must be mirrored in test_90_report_param_regression.py.
const reportDingtalkLinkText = "在钉钉中查看日志"
// ── EnrichReportListContent: pure transform ─────────────────────────────
// EnrichReportListContent overlays wukong-style agent-display fields on the
// MCP `content` map of a report list response. It is a pure function:
// - input map is never mutated;
// - on any structural mismatch (nil, no list, etc.) it returns the input
// untouched so the caller's behaviour is byte-stable.
//
// includeContent controls whether the "日志内容" column appears in
// agentDisplayColumns / agentDisplayMarkdown and whether each result row
// retains the 日志内容 key. Pass true for outbox (sender == self, content
// safe to echo), false for inbox.
func EnrichReportListContent(content map[string]any, includeContent bool) map[string]any {
if content == nil {
return content
}
items, found := findReportItemsForEnrichment(content)
if !found {
// Still attach display fields so the agent layer has a stable
// schema to read from even on empty / malformed list responses;
// rows will be empty and markdown table will only carry headers.
items = nil
}
rows := make([]map[string]string, 0, len(items))
for _, item := range items {
rows = append(rows, reportRowForItem(item, includeContent))
}
sortReportRowsByDate(rows)
columns := reportInboxColumns
if includeContent {
columns = reportOutboxColumns
}
markdown := reportRenderMarkdownTable(columns, rows)
// Build a shallow copy so callers never see in-place mutation. We
// preserve every key the upstream MCP server returned (notably
// hasMore / nextCursor / cursor at the result-wrapper level) by
// copying them into the new map verbatim.
out := make(map[string]any, len(content)+5)
for k, v := range content {
out[k] = v
}
// Replace `result` with a typed list of rows so the agent can iterate
// over a stable shape. The original `result` body is kept under the
// rebuilt key so paginator fields like nextCursor still ride at the
// expected level when MCP nested them there.
rebuiltResult := buildReportResultPayload(content, rows)
if rebuiltResult != nil {
out["result"] = rebuiltResult
} else {
// No nested wrapper to preserve — emit rows directly under result
// so callers can read result[] uniformly.
out["result"] = rowsAsAnySlice(rows)
}
out["count"] = len(rows)
if _, present := out["success"]; !present {
out["success"] = true
}
out["agentDisplayContentIncluded"] = includeContent
out["agentDisplayColumns"] = columns
out["agentDisplayMarkdown"] = markdown
return out
}
// findReportItemsForEnrichment locates the list of report items inside an
// MCP `content` map. The wukong upstream uses a permissive search across the
// well-known keys ("list", "items", "data", "reports", "records",
// "report_list", "reportList", "result"); we mirror that so envelope
// renames don't silently drop enrichment.
func findReportItemsForEnrichment(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 := reportItemsFromValueForEnrichment(value); found {
return items, true
}
}
return nil, false
}
func reportItemsFromValueForEnrichment(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 {
continue
}
items = append(items, item)
}
return items, true
case map[string]any:
// Recurse into nested wrappers (e.g. {result: {list: [...]}}).
for _, key := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList"} {
value, ok := x[key]
if !ok {
continue
}
if items, found := reportItemsFromValueForEnrichment(value); found {
return items, true
}
}
}
return nil, false
}
// buildReportResultPayload rebuilds the `result` field so that, when the
// upstream MCP wrapped items under `result.list`, the rebuilt payload keeps
// the wrapper (preserving sibling fields like nextCursor / hasMore) while
// swapping the list contents for the enriched rows. When no wrapper exists,
// returns nil and the caller emits rows directly.
func buildReportResultPayload(content map[string]any, rows []map[string]string) any {
wrapper, ok := content["result"].(map[string]any)
if !ok {
return nil
}
out := make(map[string]any, len(wrapper))
for k, v := range wrapper {
out[k] = v
}
for _, key := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList"} {
if _, found := wrapper[key]; found {
out[key] = rowsAsAnySlice(rows)
return out
}
}
// Wrapper had no recognised list key — the list must live somewhere
// else; we still publish rows at out["list"] so agents have a uniform
// place to read.
out["list"] = rowsAsAnySlice(rows)
return out
}
func rowsAsAnySlice(rows []map[string]string) []any {
out := make([]any, 0, len(rows))
for _, row := range rows {
copyRow := make(map[string]any, len(row))
for k, v := range row {
copyRow[k] = v
}
out = append(out, copyRow)
}
return out
}
// reportRowForItem extracts the four base columns (date / title / sender /
// status) plus the dingtalk markdown link from a single item map. The
// "日志内容" column is injected only when includeContent=true; inbox callers
// pass false to enforce the privacy contract (no inbox bodies leaked back).
func reportRowForItem(item map[string]any, includeContent bool) map[string]string {
row := map[string]string{
"日期": reportItemDate(item),
"标题": reportItemTitle(item),
"发送人": reportItemSender(item),
"状态": reportItemStatus(item),
"钉钉链接": reportItemMarkdownLink(item),
}
if includeContent {
row["日志内容"] = reportItemContent(item)
}
return row
}
func reportItemDate(item map[string]any) string {
for _, key := range []string{"createTime", "gmtCreate", "sendTime", "time", "modifiedTime", "日期"} {
if ms := reportMillisFromValue(item[key]); ms > 0 {
return time.UnixMilli(ms).Local().Format("2006-01-02 15:04")
}
if s := reportStringFromValue(item[key]); s != "" {
return s
}
}
return ""
}
func reportItemTitle(item map[string]any) string {
for _, key := range []string{"report_name", "reportName", "title", "summary", "report_template_name", "templateName", "标题"} {
if s := reportStringFromValue(item[key]); s != "" {
return s
}
}
if sender := reportItemSender(item); sender != "" {
return sender + "的日志"
}
return "日志"
}
func reportItemSender(item map[string]any) string {
for _, key := range []string{"creatorName", "senderName", "userName", "creator", "sender", "发送人"} {
if s := reportStringFromValue(item[key]); s != "" {
return s
}
}
return ""
}
func reportItemStatus(item map[string]any) string {
for _, key := range []string{"readStatus", "isRead", "hasRead", "read", "状态"} {
v, ok := item[key]
if !ok {
continue
}
switch x := v.(type) {
case bool:
if x {
return "已读"
}
return "未读"
case string:
lower := strings.TrimSpace(strings.ToLower(x))
switch lower {
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 ""
}
// reportItemMarkdownLink prefers an upstream-supplied markdown link
// ("dingtalkOpenMarkdownLink"); otherwise it composes one from any URL field
// it can find on the item. As a last resort it emits the plain label "查看
// 详情" so the column is never empty (tests assert the column header always
// exists and the marker shows when count>0; an empty cell would still
// satisfy markdown but would degrade the user-facing render).
func reportItemMarkdownLink(item map[string]any) string {
if s := reportStringFromValue(item["dingtalkOpenMarkdownLink"]); s != "" {
return s
}
if s := reportStringFromValue(item["钉钉链接"]); s != "" {
return s
}
for _, key := range []string{"url", "dingtalkOpenUrl", "openUrl", "webUrl"} {
if s := reportStringFromValue(item[key]); s != "" {
return fmt.Sprintf("[%s](%s)", reportDingtalkLinkText, s)
}
}
if link, ok := item["dingtalkOpenLink"].(map[string]any); ok {
if s := reportStringFromValue(link["url"]); s != "" {
return fmt.Sprintf("[%s](%s)", reportDingtalkLinkText, s)
}
}
return "查看详情"
}
func reportItemContent(item map[string]any) string {
for _, key := range []string{"report_content", "reportContent", "content", "summary", "日志内容"} {
v, ok := item[key]
if !ok {
continue
}
if s := reportFlattenContent(v); s != "" {
return reportCompactCell(s, 600)
}
}
return ""
}
func reportFlattenContent(v any) string {
switch x := v.(type) {
case string:
return strings.TrimSpace(x)
case []any:
parts := make([]string, 0, len(x))
for _, item := range x {
if s := reportFlattenContent(item); s != "" {
parts = append(parts, s)
}
}
return strings.Join(parts, ";")
case map[string]any:
label := reportFirstString(x, "key", "field_name", "fieldName", "name", "title")
value := reportFirstString(x, "value", "content", "text", "plainText", "markdown")
if value == "" {
return ""
}
if label != "" {
return label + ":" + value
}
return value
}
return ""
}
func reportFirstString(m map[string]any, keys ...string) string {
for _, k := range keys {
if s := reportStringFromValue(m[k]); s != "" {
return s
}
}
return ""
}
func reportStringFromValue(v any) string {
switch x := v.(type) {
case string:
return strings.TrimSpace(x)
case float64:
return strconv.FormatFloat(x, 'f', -1, 64)
case json.Number:
return x.String()
case bool:
if x {
return "true"
}
return "false"
}
return ""
}
// reportMillisFromValue normalises numeric / numeric-string timestamps to
// milliseconds. Second-precision values (< 1e11) are scaled up. Non-numeric
// inputs return 0 so the caller can fall back to a string formatter.
func reportMillisFromValue(v any) int64 {
switch x := v.(type) {
case float64:
return normaliseReportTimestamp(int64(x))
case int64:
return normaliseReportTimestamp(x)
case int:
return normaliseReportTimestamp(int64(x))
case json.Number:
n, err := x.Int64()
if err != nil {
return 0
}
return normaliseReportTimestamp(n)
case string:
s := strings.TrimSpace(x)
if s == "" {
return 0
}
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
return normaliseReportTimestamp(n)
}
}
return 0
}
func normaliseReportTimestamp(ts int64) int64 {
if ts <= 0 {
return 0
}
if ts < 100000000000 {
ts *= 1000
}
return ts
}
func reportCompactCell(s string, maxRunes int) string {
s = strings.ReplaceAll(s, "|", "|")
s = strings.Join(strings.Fields(s), " ")
if maxRunes <= 0 {
return s
}
r := []rune(s)
if len(r) <= maxRunes {
return s
}
return string(r[:maxRunes]) + "..."
}
// sortReportRowsByDate sorts rows by date string descending so the most
// recent log appears first — matches the wukong upstream's user-visible
// ordering. String compare is safe because dates are formatted as
// "2006-01-02 15:04" (lexicographic order == chronological order).
func sortReportRowsByDate(rows []map[string]string) {
sort.SliceStable(rows, func(i, j int) bool {
return rows[i]["日期"] > rows[j]["日期"]
})
}
// reportRenderMarkdownTable composes a standard GFM markdown table from the
// given column header and row maps. Cell values are sanitised so that
// embedded pipes do not break the table; long whitespace runs are squashed.
func reportRenderMarkdownTable(columns []string, rows []map[string]string) string {
var b strings.Builder
b.WriteString("| ")
b.WriteString(strings.Join(columns, " | "))
b.WriteString(" |")
b.WriteString("\n")
dividers := make([]string, len(columns))
for i := range dividers {
dividers[i] = "---"
}
b.WriteString("| ")
b.WriteString(strings.Join(dividers, " | "))
b.WriteString(" |")
for _, row := range rows {
b.WriteString("\n")
cells := make([]string, 0, len(columns))
for _, col := range columns {
cells = append(cells, reportCellForMarkdown(row[col]))
}
b.WriteString("| ")
b.WriteString(strings.Join(cells, " | "))
b.WriteString(" |")
}
return b.String()
}
func reportCellForMarkdown(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, "|", "|")
}
// ── AttachReportListReadableEnrichment: post-merge hook ─────────────────
// AttachReportListReadableEnrichment wires EnrichReportListContent into the
// envelope-built `report inbox list` and `report outbox list` leaves.
//
// Mechanism (decorator over the leaf's existing RunE):
// 1. Replace leaf.RunE with a closure that delegates to the original RunE.
// 2. Before delegating, redirect cmd.SetOut() to an in-memory buffer when
// the resolved output format is JSON (the only format the agent display
// schema cares about).
// 3. After the original RunE returns, unmarshal the captured bytes, locate
// the MCP `content` payload, call EnrichReportListContent, and write
// the enriched JSON to the leaf's original stdout.
// 4. For non-JSON formats (raw / table / csv / ...) we never replace stdout,
// so behaviour stays byte-for-byte identical to the unwrapped envelope.
//
// `runner` is accepted for API symmetry with AttachReportLegacyInboxAlias —
// the wrapper itself does not invoke runner; the wrapped RunE already does.
// Keeping the parameter avoids a churn on app/legacy.go if we ever need to
// dispatch a sibling tool from inside the wrapper.
func AttachReportListReadableEnrichment(commands []*cobra.Command, runner executor.Runner) {
_ = runner // reserved — see comment above
for _, top := range commands {
if top == nil || top.Name() != "report" {
continue
}
wrapReportListLeaf(top, "inbox", false)
wrapReportListLeaf(top, "outbox", true)
}
}
func wrapReportListLeaf(reportCmd *cobra.Command, groupName string, includeContent bool) {
var group *cobra.Command
for _, child := range reportCmd.Commands() {
if child != nil && child.Name() == groupName {
group = child
break
}
}
if group == nil {
return
}
var leaf *cobra.Command
for _, child := range group.Commands() {
if child != nil && child.Name() == "list" {
leaf = child
break
}
}
if leaf == nil {
return
}
originalRunE := leaf.RunE
if originalRunE == nil {
return
}
leaf.RunE = func(cmd *cobra.Command, args []string) error {
// Resolve the format the user asked for. Enrichment only applies
// when the eventual writer would emit JSON — other formats stay
// untouched so `--format table`, `--format raw`, etc. remain
// byte-stable against the envelope.
fmtChoice := output.ResolveFormat(cmd, output.FormatJSON)
if fmtChoice != output.FormatJSON {
return originalRunE(cmd, args)
}
originalStdout := cmd.OutOrStdout()
buf := &bytes.Buffer{}
cmd.SetOut(buf)
runErr := originalRunE(cmd, args)
// Restore the leaf's original writer regardless of whether the
// run succeeded — if it failed we still want any partial bytes
// (rare, defensive) flushed back to the caller.
cmd.SetOut(originalStdout)
if runErr != nil {
// Pass through any captured bytes the inner RunE produced
// before failing so the caller's logs / stderr show the
// same diagnostic context as the un-wrapped envelope.
if buf.Len() > 0 {
_, _ = originalStdout.Write(buf.Bytes())
}
return runErr
}
enriched, ok := enrichCapturedReportListJSON(buf.Bytes(), includeContent)
if !ok {
// Captured payload did not match the expected shape (e.g.
// dry-run output, non-content envelope, malformed JSON) —
// echo the original bytes verbatim so the caller's behaviour
// is unchanged for those code paths.
_, _ = originalStdout.Write(buf.Bytes())
return nil
}
return output.WriteJSON(originalStdout, enriched)
}
}
// enrichCapturedReportListJSON parses bytes the wrapped RunE wrote to its
// captured stdout buffer and, if the payload is the unwrapped MCP `content`
// map (the shape produced by output.WriteCommandPayload for successful
// compat_invocation Results), applies EnrichReportListContent and returns
// the enriched payload. Returns ok=false for any shape the function does
// not recognise so the caller falls back to passthrough.
func enrichCapturedReportListJSON(raw []byte, includeContent bool) (any, bool) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 {
return nil, false
}
var decoded any
if err := json.Unmarshal(trimmed, &decoded); err != nil {
return nil, false
}
root, ok := decoded.(map[string]any)
if !ok {
return nil, false
}
// Case A: payload is the MCP `content` map directly (the post-unwrap
// shape that output.WriteCommandPayload emits for successful
// compat_invocation results in --format json). Enrich in place.
if _, hasResult := root["result"]; hasResult || hasReportListKey(root) {
return EnrichReportListContent(root, includeContent), true
}
// Case B: dry-run / error envelopes carry {invocation, response}. We
// leave those untouched — the agent-display schema only applies to
// successful list responses; dry-run is for inspection.
return nil, false
}
// hasReportListKey reports whether the map looks like an MCP list response
// even if `result` is absent (some servers return `list` at the top level).
func hasReportListKey(m map[string]any) bool {
for _, k := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList"} {
if _, ok := m[k]; ok {
return true
}
}
return false
}