Ctrl K
ComunidadGuías…

docs/plan/2026-09-24-dingtalk-channel-technical-design.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 钉钉机器人通道技术方案

调研见 [docs/research/2026-09-24-dingtalk-channel-research.md](../research/2026-09-24-dingtalk-channel-research.md)。本文是落地设计,按当前飞书通道(`backend-ts/src/feishu/`)的分层来接,不新造执行引擎。

## 1. 背景

Mao 已有飞书自建应用机器人通道:管理后台多机器人、WebSocket 长连接、私聊与群 @、身份绑定、云端会话、入站排队、进度卡。钉钉用**企业内部应用机器人 + Stream 模式**走同一条链路。现有「任务完成通知」里的钉钉地址是自定义机器人 Webhook,只能往群里推文本,不扩展成通道。

钉钉和飞书的差异已经在调研里定过产品范围,本方案直接按那个范围实现。

## 2. 范围

### 2.1 要做

1. 管理后台配置多个企业内部机器人。每个机器人独立 Agent / 模型,Stream 随启停热生效。
2. 用户在桌面 / Web / 安卓设置页绑定、解绑钉钉身份。未绑定的人在钉钉里收到「点我绑定」卡,登录 Mao 后完成绑定,并重放刚才那条消息。
3. 私聊:文本、富文本、图片、文件。群:@ 机器人之后的文本、富文本、图片。
4. 回复走主动发送接口,不依赖 `sessionWebhook`。终态正文是一条 markdown;进度和排队用互动卡片。
5. 进度卡:执行中 / 完成 / 失败 / 取消,展示轮次摘要和耗时。按钮:取消、失败重试、打开网页会话。只允许原发送者点。
6. 会话忙时排队。排队卡:立即发送(打断当前任务)、取消本条。
7. 私聊 `---` 新建会话,并成为该私聊在钉钉侧的唯一活跃会话。执行中收到 `---` 先取消当前任务再新建。
8. 群上下文只注入本机器人收到的 @ 往来,加上本条自带的引用正文。
9. 已打开的网页 / 桌面 / 安卓会话跟上这一轮执行,复用现有 WebSocket 广播。

### 2.2 不做

| 不做 | 落地约定 |
|------|----------|
| `ask_user_questions` | 钉钉会话从工具列表去掉,与微信通道相同。不发提问卡,不把下一条文字解析成答案 |
| 列出会话、切换会话、引用切会话 | 没有 `会话` / `切换 N`。引用只注入正文,不改活跃会话 |
| 话题群、多 Thread 并行 | 不建话题表。群一个 `conversationId` 一个会话。私聊钉钉侧同时只有一个活跃会话 |
| 群内未 @ 消息 | 平台不推,不接入个人 IM 事件和消息菜单 |
| 出站引用回复 | 发送接口没有 reply 参数,回复是一条新消息 |
| 群文件 / 语音 / 视频 | 群 @ 收不到这些类型。单聊收到语音、视频时回复「暂不支持」,不把语音识别文本当任务 |
| 钉钉文档读取、组织架构注入 | 不调用文档和通讯录详情 |
| ECP 票闸门 | 钉钉绑定成功即可用。ECP 只服务飞书登录 |
| 自定义机器人 Webhook | 任务通知通道保持不动 |
| 多实例同时拉同一个 Client ID 的 Stream | 与飞书相同,只在单实例上跑 |
| 钉钉 OAuth 作为 Mao 登录方式 | 钉钉身份只做通道绑定,不创建用户 |

## 3. 架构

```
钉钉客户端(私聊 / 群 @)
        │ Stream
        ▼
backend-ts/src/dingtalk/
├─ monitor.service.ts         # 扫启用机器人,一机器人一个 dingtalk-stream 客户端
├─ event-normalizer.ts        # 收成内部消息
├─ token.ts                   # 新版 accessToken 与旧版 gettoken 分开缓存
├─ inbound-processor.ts       # 去重、绑定、下载、群上下文、交给 handler
├─ agent-inbound-handler.ts   # 排队、---、取消、进度卡、调用 HarnessService
├─ card-action.service.ts     # 卡片回调:取消、重试、排队
├─ card-client.ts             # 创建投放、按 outTrackId 更新变量
├─ message.service.ts         # 私聊指针、群会话、工作区
├─ send.service.ts            # 主动发送文本 / markdown
├─ media.ts                   # downloadCode 换链、媒体上传
├─ binding.routes.ts          # 设置页绑定 / 解绑 / OAuth 回调 / 短链
└─ admin.routes.ts            # 机器人 CRUD、连接状态、重连
        │
        ▼
SessionService / HarnessService / 现有 WebSocket 广播
```

依赖:`dingtalk-stream` 只负责长连接。OpenAPI 用 Node `fetch` 调 `api.dingtalk.com` 和 `oapi.dingtalk.com`,不引入 Tea SDK。

处理链路:

```
Stream 回调
  ├─ 立刻 ACK,Agent 放到后台
  ├─ (bot_id, msg_id) 去重
  ├─ 无 senderStaffId / 未绑定 → 绑定卡,结束
  ├─ 私聊 --- → 取消当前执行,新建会话,回复确认,结束
  ├─ 下载图片 / 文件
  ├─ 群:写入 @ 日志,拼「@ 往来 + 引用正文」
  ├─ 独立文件:只落盘,不跑 Agent
  └─ 会话空闲则执行,否则入队并发排队卡
执行过程更新进度卡,并把事件推到已连接的网页客户端
终态:更新进度卡 + 另发一条 markdown 正文
```

卡片回调同样先在 2 秒内返回新变量,取消 / 重试 / 插队的重活放在返回之后。回调处理过程中不调「更新卡片」接口。

## 4. 数据模型

迁移 `V120__dingtalk_bot_channel.sql`。身份、会话、队列都独立成表,不复用 `feishu_*`。

```sql
CREATE TABLE IF NOT EXISTS `dingtalk_bot` (
    `id`            BIGINT PRIMARY KEY AUTO_INCREMENT,
    `app_key`       VARCHAR(64)  NOT NULL COMMENT '内部唯一标识',
    `name`          VARCHAR(128) NOT NULL,
    `client_id`     VARCHAR(128) NOT NULL COMMENT 'Client ID / AppKey',
    `client_secret` VARCHAR(512) NOT NULL COMMENT 'AES-GCM',
    `robot_code`    VARCHAR(128) NOT NULL COMMENT '发送与下载使用,不假定等于 client_id',
    `agent_id`      BIGINT       NULL,
    `model_id`      BIGINT       NULL,
    `progress_card_template_id` VARCHAR(128) NULL,
    `queue_card_template_id`    VARCHAR(128) NULL,
    `enabled`       TINYINT      NOT NULL DEFAULT 1,
    `deleted`       TINYINT      NOT NULL DEFAULT 0,
    `created_at`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY `uk_dingtalk_bot_app_key` (`app_key`),
    UNIQUE KEY `uk_dingtalk_bot_client_id` (`client_id`)
);

CREATE TABLE IF NOT EXISTS `dingtalk_binding` (
    `id`         BIGINT PRIMARY KEY AUTO_INCREMENT,
    `user_id`    BIGINT       NOT NULL,
    `union_id`   VARCHAR(128) NOT NULL,
    `userid`     VARCHAR(128) NOT NULL COMMENT '企业内 senderStaffId',
    `deleted`    TINYINT      NOT NULL DEFAULT 0,
    `active_union_id` VARCHAR(128) GENERATED ALWAYS AS (IF(`deleted` = 0, `union_id`, NULL)) STORED,
    `active_userid`   VARCHAR(128) GENERATED ALWAYS AS (IF(`deleted` = 0, `userid`, NULL)) STORED,
    `active_user_id`  BIGINT       GENERATED ALWAYS AS (IF(`deleted` = 0, `user_id`, NULL)) STORED,
    `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY `uk_dingtalk_binding_union_active` (`active_union_id`),
    UNIQUE KEY `uk_dingtalk_binding_userid_active` (`active_userid`),
    UNIQUE KEY `uk_dingtalk_binding_user_active` (`active_user_id`)
);

-- 私聊活跃指针与群会话共用。私聊 conversation_id 用钉钉回调里的 conversationId。
CREATE TABLE IF NOT EXISTS `dingtalk_chat` (
    `id`            BIGINT PRIMARY KEY AUTO_INCREMENT,
    `bot_id`        BIGINT       NOT NULL,
    `conversation_id` VARCHAR(128) NOT NULL,
    `chat_type`     VARCHAR(16)  NOT NULL COMMENT 'p2p / group',
    `session_id`    BIGINT       NOT NULL,
    `owner_user_id` BIGINT       NOT NULL,
    `workspace`     VARCHAR(512) NULL,
    `title`         VARCHAR(128) NULL COMMENT '群标题快照,可空',
    `created_at`    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at`    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY `uk_dingtalk_chat` (`bot_id`, `conversation_id`)
);

CREATE TABLE IF NOT EXISTS `dingtalk_chat_member` (
    `id`           BIGINT PRIMARY KEY AUTO_INCREMENT,
    `bot_id`       BIGINT       NOT NULL,
    `conversation_id` VARCHAR(128) NOT NULL,
    `user_id`      BIGINT       NOT NULL,
    `userid`       VARCHAR(128) NOT NULL,
    `display_name` VARCHAR(128) NULL,
    `created_at`   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY `uk_dingtalk_chat_member` (`bot_id`, `conversation_id`, `userid`)
);

-- 只有机器人收到的群消息(即 @)。不是完整群聊。
CREATE TABLE IF NOT EXISTS `dingtalk_group_message_log` (
    `id`          BIGINT PRIMARY KEY AUTO_INCREMENT,
    `bot_id`      BIGINT       NOT NULL,
    `conversation_id` VARCHAR(128) NOT NULL,
    `sender_userid` VARCHAR(128) NOT NULL,
    `sender_name` VARCHAR(128) NOT NULL,
    `direction`   VARCHAR(8)   NOT NULL COMMENT 'IN / OUT',
    `content`     TEXT         NULL,
    `message_id`  VARCHAR(128) NULL,
    `created_at`  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    KEY `idx_dingtalk_group_log` (`bot_id`, `conversation_id`, `id`)
);

CREATE TABLE IF NOT EXISTS `dingtalk_inbound_event` (
    `id`         BIGINT PRIMARY KEY AUTO_INCREMENT,
    `bot_id`     BIGINT       NOT NULL,
    `message_id` VARCHAR(128) NOT NULL,
    `chat_id`    VARCHAR(128) NULL,
    `status`     VARCHAR(16)  NOT NULL COMMENT 'CLAIMED / DONE / FAILED',
    `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY `uk_dingtalk_inbound_event` (`bot_id`, `message_id`)
);

CREATE TABLE IF NOT EXISTS `dingtalk_inbound_queue` (
    `id`              BIGINT PRIMARY KEY AUTO_INCREMENT,
    `bot_id`          BIGINT       NOT NULL,
    `session_id`      BIGINT       NOT NULL,
    `message_id`      VARCHAR(128) NOT NULL,
    `out_track_id`    VARCHAR(128) NULL COMMENT '排队卡实例 ID',
    `sender_userid`   VARCHAR(128) NOT NULL,
    `mao_user_id`     BIGINT       NULL,
    `rank_no`         BIGINT       NOT NULL,
    `status`          VARCHAR(16)  NOT NULL DEFAULT 'QUEUED',
    `payload`         MEDIUMTEXT   NOT NULL,
    `created_at`      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at`      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY `uk_dingtalk_queue_message` (`bot_id`, `message_id`),
    KEY `idx_dingtalk_queue_session` (`session_id`, `status`, `rank_no`)
);

CREATE TABLE IF NOT EXISTS `dingtalk_progress_card` (
    `session_id`    BIGINT PRIMARY KEY,
    `bot_id`        BIGINT       NOT NULL,
    `out_track_id`  VARCHAR(128) NOT NULL,
    `chat_type`     VARCHAR(16)  NOT NULL,
    `conversation_id` VARCHAR(128) NULL,
    `sender_userid` VARCHAR(128) NULL,
    `created_at`    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at`    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS `dingtalk_oauth_state` (
    `id`         BIGINT PRIMARY KEY AUTO_INCREMENT,
    `state`      VARCHAR(64)  NOT NULL,
    `user_id`    BIGINT       NULL COMMENT '设置页发起时已有;钉钉内发起时先空,登录后再填',
    `status`     VARCHAR(16)  NOT NULL,
    `expires_at` DATETIME     NOT NULL,
    `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY `uk_dingtalk_oauth_state` (`state`)
);

CREATE TABLE IF NOT EXISTS `dingtalk_pending_binding_message` (
    `id`         BIGINT PRIMARY KEY AUTO_INCREMENT,
    `state`      VARCHAR(64)  NOT NULL,
    `bot_id`     BIGINT       NOT NULL,
    `payload`    MEDIUMTEXT   NOT NULL,
    `status`     VARCHAR(16)  NOT NULL,
    `expires_at` DATETIME     NOT NULL,
    `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY `uk_dingtalk_pending_state` (`state`)
);
```

去重语义对齐 `feishu_inbound_event`:`CLAIMED` 且超过 10 分钟,或 `FAILED`,允许再次认领;`DONE` 直接丢弃。处理失败要 `release`,避免一条消息卡死。

## 5. 连接

`DingtalkMonitorService` 对齐 `FeishuMonitorService`:`DINGTALK_BOT_ENABLED` 打开后按 5 秒扫 `dingtalk_bot`,启用的拉起 Stream,停用或软删的关掉。每个机器人:

- Topic `/v1.0/im/bot/messages/get` → 归一化后 `void processor.process()`,函数立刻返回。
- Topic `/v1.0/card/instances/callback` → 卡片动作。不注册 `*` 事件。
- SDK 负责心跳和重连。连续失败打日志,管理端能看到 `ready / reconnecting / failed / disabled`。
- 同一 Client ID 只保留这一条连接。本地调试用另一个应用。

管理端 `POST /v1/admin/dingtalk-bots/:id/reconnect` 只对已启用的机器人生效。

## 6. 收消息

归一化结果只保留通道内部字段:`chatType`(`p2p` / `group`)、`conversationId`、`messageId`、`senderUserid`、`senderUnionId`、`senderName`、`msgtype`、`text`、`downloadCodes`、`fileName`、`quotedText`。`senderId`(加密字段)丢弃。

| 回调 | 内部 |
|------|------|
| `conversationType=1` | `p2p` |
| `conversationType=2` | `group` |
| `senderStaffId` | `senderUserid` |
| `text.content` | trim。去掉 @ 后的前导空格 |
| `richText` | 拼接文本,图片段收集 `downloadCode` |
| `picture` / `file` | `content.downloadCode` |
| `text.isReplyMsg` + `repliedMsg`,或旧字段 `quoteMessage` | 有正文则写入 `quotedText`,没有则空。不读它做会话切换 |

群消息若 `isInAtList=false`,只记日志,不跑 Agent。

`senderStaffId` 为空(未发布、外部群、外部成员):回复「当前无法识别你的钉钉身份」,不发绑定卡,不用加密 `senderId` 凑绑定。

## 7. 绑定

钉钉 OAuth **不是** Mao 登录。绑定把「当前已登录的 Mao 用户」和「钉钉 userid」连起来。一个 userid 对应一个 Mao 用户,一个 Mao 用户对应一个钉钉身份。冲突时提示已绑定其他账号,不自动改绑。解绑只把 `deleted` 置 1,会话和工作区保留。

OAuth 使用单独配置的登录应用(`DINGTALK_OAUTH_CLIENT_ID` / `SECRET` / `REDIRECT_URI`),与机器人 Stream 凭证分开。该应用必须和机器人在同一企业,否则 `根据 unionid 查询用户` 得不到 userid。可以把某个机器人的 Client ID 同时配成这三项。

流程:

1. 设置页(已登录)`POST /v1/dingtalk/binding` 生成 state(写入 `user_id`)和授权链接。
2. 钉钉内未绑定:发链接卡片,按钮 URL 是 Mao 短链 `/api/v1/dingtalk/bind/{state}`,3 分钟有效。短链上没有登录态时先到现有登录页,登录成功后再 302 到钉钉授权页。
3. 回调用 authCode 换用户 token,调「获取用户通讯录个人信息」(`unionId=me`),再用应用 token 把 unionId 换成 userid。
4. 写入 `dingtalk_binding`。若 state 挂着待重放消息,绑定成功后按原消息跑一次。
5. `GET /v1/dingtalk/binding/status`、`DELETE /v1/dingtalk/binding`。

入站匹配先 `userid`,没有再 `senderUnionId`。两处都没有则未绑定。

未配置 OAuth 三项时,设置页绑定返回明确错误;钉钉内只回复「管理员尚未配置钉钉登录,暂时无法绑定」,不发空按钮。

## 8. 会话与工作区

执行模式 `CLOUD`,权限 `FULL`。Agent / 模型取该机器人配置,空则默认。`executionUserId` 用触发者的 Mao 用户,群会话的 `session.userId` 用群内第一个完成绑定的成员。

| | 私聊 | 群 |
|--|------|----|
| 键 | `(bot_id, conversationId)` 一行,`session_id` 是活跃指针 | 同一张表,一个群一行 |
| projectKey | `dingtalk-{botId}-private-{maoUserId}`,同一用户的多个私聊会话共用,便于网页侧边栏分组 | `dingtalk-chat-{botId}-{conversationId}` |
| 工作区 | `{workspaceRoot}/dingtalk-chat/{botId}/p2p-{maoUserId}/`,`---` 换会话不换目录 | `{workspaceRoot}/dingtalk-chat/{botId}/{conversationId}/` |
| 成员 | 不使用 | 已绑定且 @ 过的人写入 `dingtalk_chat_member`。未入表的已绑定用户第一次 @ 时入表 |

`---` 只在私聊生效,判定与飞书相同:trim 后是 3 个及以上 `-` 或 `—`。群里的 `---` 当普通文本。

私聊 `---`:

1. 当前活跃会话正在执行:置取消标志,按飞书 `persistFeishuCancelIfIdle` 的策略收尾(内存里有循环就只靠标志,不提前写 CANCELLED)。
2. 新建 session 行,把 `dingtalk_chat.session_id` 指到它。旧会话留在网页端,钉钉不再投递。
3. 回复「已开启新会话,后续消息将在新的上下文中处理。」这条确认不进入 Agent。
4. 新建失败则回复失败文案,指针不动。

普通消息打到指针上的会话。指针不存在时懒创建。同一私聊不会有两条钉钉执行并行。

群会话忙时入队,不因为下一次 @ 取消当前任务。取消只来自进度卡「取消任务」或排队卡「立即发送」。

侧边栏分组在 `session-group-key.ts` 增加 `DINGTALK_PRIVATE:` / `DINGTALK_GROUP:`,规则照飞书:私聊按 agent 聚合,群按工作区路径一条。`ofMode` 把含 `/dingtalk-chat/` 的云端工作区排除在「临时工作区」之外。

## 9. 入站内容

下载:`POST https://api.dingtalk.com/v1.0/robot/messageFiles/download`,body 为 `downloadCode` + 该机器人的 `robotCode`,再拉 `downloadUrl`。下载码过期或与 robotCode 不匹配时,在文本末尾追加 `[以下文件接收失败:…]`,任务仍可继续。

| 类型 | 行为 |
|------|------|
| 文本 | 直接进入任务 |
| 图片、富文本中的图片 | data URI 进多模态,并落在 `{workspace}/chat-files/{yyyy-MM-dd}/`。消息里附保存路径 |
| 私聊文件 | 只落盘并写一条带 `@{路径}@` 的用户消息,不跑 Agent。下一条文字再跑。只发文件、没有后续文字则保持静默 |
| 群里出现文件、语音、视频 | 平台通常不投递。若仍收到,回复「群聊暂不支持该类型,请私聊发送」,不跑 Agent |
| 私聊语音、视频 | 回复「暂不支持语音和视频」,不跑 Agent |
| 引用正文 | 放在用户文本前,标题用「引用的消息」。没有正文则省略。不切换会话 |

群 @ 触发时,在用户文本前附加:

```
【群内 @ 机器人的最近往来】(不是完整群聊)
[HH:mm] 张三:……
[HH:mm] 机器人:……(出站摘要,截断)
【引用的消息】
……
【用户消息】
……
```

窗口默认最近 10 条且 2 小时内,可用 `DINGTALK_BOT_GROUP_CONTEXT_MAX_ITEMS` / `MAX_MINUTES` 调整。出站成功后把回复摘要写入同一张日志,`direction=OUT`。这段文本不写入会话 message 表的历史,只附在本次触发消息上,避免污染标题和压缩。

未绑定的 @ 仍记一条入站日志,便于绑定重放前不丢上下文素材,但不创建群工作区。

## 10. 执行、排队、进度卡

`agent-inbound-handler` 的互斥、忙会话、入队、出队对齐飞书,差异只有三处:没有话题分支,没有引用切会话,`---` 会取消当前执行。

排队卡按钮:

- 「立即发送」:把该条插到队首并取消当前执行,然后出队。
- 「取消本次任务」:只取消这一条,不停止正在跑的任务。

进度卡在任务开始时 `createAndDeliver`,之后用「更新卡片」按 `outTrackId` 改 `cardParamMap`。不用 `PUT /v1.0/card/streaming`,避免 1KB / 3KB 的流式上限。正文不写进卡片。

投放场域:

- 私聊 `openSpaceId=dtv1.card//IM_ROBOT.{senderUserid}`,带 `imRobotOpenSpaceModel`。
- 群 `openSpaceId=dtv1.card//IM_GROUP.{conversationId}`,带 `imGroupOpenSpaceModel`。
- `callbackType=STREAM`。`userIdType` 使用企业 userid。

`dingtalk_progress_card` 记下 `session_id → outTrackId`。终态(完成、失败、取消)和进程重启后续跑,都更新这张卡。更新失败只打日志,不让任务失败。崩溃恢复沿用现有 Harness 恢复;恢复挂上监听后按该表找到卡片。内存里没有循环、库里仍是 RUNNING 时,进度卡取消走与飞书相同的「补写 CANCELLED 并排空队列」。

进度变量(值全部是字符串,布尔用 `"true"` / `"false"`):

| 变量 | 含义 |
|------|------|
| `title` | 正在处理 / 处理完成 / 处理失败 / 任务已取消 |
| `body` | 轮次、耗时、最近工具摘要、失败时的 `error.message`。控制在 1500 字以内 |
| `status` | `running` / `completed` / `failed` / `cancelled`,给模板做按钮显隐 |
| `sessionUrl` | `{网页 origin}/tasks/{sessionId}`。origin 与飞书进度卡相同,取 ECP `desktopCallbackUrl`;取不到则空,模板隐藏按钮 |

按钮回传参数带 `sessionId` 与 `senderUserid`。点击者的 `userId` 必须等于 `senderUserid`,否则 toast「仅消息发送者可操作」,卡片不变。

- 取消、重试的语义与飞书进度卡相同:重试不插入新的用户消息,会话正在执行或已结束时 toast 不可用。
- 「会话详情」是打开链接,不走回调。

排队卡变量:`title`、`preview`(用户文本截断)、`status`(`queued` / `started` / `cancelled`)。回传参数带 `queueId` 和 `senderUserid`。

模板 ID 优先用机器人行上的 `progress_card_template_id` / `queue_card_template_id`,空则用环境变量。两处都空:任务照跑,进度退化为一条「正在处理」文本,终态仍发 markdown。不因此失败。

卡片平台需要发布两张模板,变量名与上表一致。按钮 actionId 固定为 `cancel`、`retry`、`run`。结构变更要重新发布模板;文案和摘要走变量,不用改模板。

## 11. 出站

凭证:

- 发消息、下载、投放卡片:`POST https://api.dingtalk.com/v1.0/oauth2/accessToken`,请求头 `x-acs-dingtalk-access-token`。提前刷新。
- 上传媒体:`GET https://oapi.dingtalk.com/gettoken`。另一套缓存,不混用。

| 场景 | 接口 | 寻址 |
|------|------|------|
| 私聊 | `POST /v1.0/robot/oToMessages/batchSend` | `userIds: [senderUserid]` |
| 群 | `POST /v1.0/robot/groupMessages/send` | `openConversationId=conversationId` |

文本回复用 `sampleMarkdown`(`title` 取首行截断,`text` 为正文)。默认最多 2000 字,超出截断并追加「…(回复过长已截断)」,配置项 `DINGTALK_BOT_REPLY_MAX_LENGTH`。若接口返回内容过长,再降到 500 字重试一次,仍失败则在进度卡 `body` 写入失败原因。

不使用 `sessionWebhook`。绑定引导、错误提示、`---` 确认也走主动发送。

图片和文件由工具发送,见第 12 节。上传 `https://oapi.dingtalk.com/media/upload` 拿到 `media_id`。图片 `sampleImageMsg` 的 `photoURL` 使用上传后钉钉可识别的地址;若该字段不接受 `media_id`,则图片消息改用文档中接受 `media_id` 的图片模板,实现时以当时的「企业机器人发送消息类型」为准,并补一条契约测试挡住发不出去的参数。文件 `sampleFile`,扩展名只允许 xlsx、pdf、zip、rar、doc、docx,最大 20MB。

## 12. 工具

`HarnessService.filterToolsForSession` 增加钉钉分支,判定与飞书对称:`projectKey` 匹配 `^dingtalk-\d+-private-\d+$`,或工作区路径含 `/dingtalk-chat/`。

该分支去掉:

- `ask_user_questions`
- 微信通道工具
- 飞书通道工具

其他会话去掉钉钉通道工具。子代理继续去掉 `ask_user_questions`,并去掉钉钉通道工具。

新增两个内置工具,标记为钉钉通道工具,只在钉钉会话出现:

| 工具 | 行为 |
|------|------|
| `dingtalk_send_image` | 读工作区图片并发送。成功返回文件名 |
| `dingtalk_send_file` | 读工作区文件并发送。扩展名或大小不合法时返回失败原因,不抛到任务失败 |

同步 `tool-result-summarizer.ts` 和 `desktop/src/utils/toolDisplay.ts` 的中文名、参数预览,以及成功、失败、缺参数的回归测试。不注册 `dingtalk_read_doc`。

`generate_image` / `edit_image` 的工具说明里补一句结果可交给 `dingtalk_send_image`。

## 13. 管理端与设置页

管理后台在「飞书机器人」旁增加「钉钉机器人」,页面照 `FeishuBotListView.vue`。接口要求管理员:

| 方法 | 路径 |
|------|------|
| GET | `/v1/admin/dingtalk-bots` |
| GET | `/v1/admin/dingtalk-bots/:id` |
| POST | `/v1/admin/dingtalk-bots` |
| PUT | `/v1/admin/dingtalk-bots/:id` |
| DELETE | `/v1/admin/dingtalk-bots/:id` |
| POST | `/v1/admin/dingtalk-bots/:id/enable` 与 `/disable` |
| GET | `/v1/admin/dingtalk-bots/status` |
| POST | `/v1/admin/dingtalk-bots/:id/reconnect` |

列表不返回 `clientSecret`,只返回 `clientSecretConfigured`。更新时 secret 空串表示不改。未配置 `APP_DINGTALK_BOT_SECRET` 时创建和修改 secret 返回「未配置 APP_DINGTALK_BOT_SECRET」。

设置页在「飞书Bot」旁增加「钉钉Bot」,桌面 / Web / 安卓共用,不改安卓原生。展示绑定状态、绑定、解绑。

实现时同步:

- 根 `CHANGELOG.md` 顶部版本说明(后端、管理后台、前端)。
- `skills/mao-cli/SKILL.md` 索引与 `skills/mao-cli/reference/dingtalk-bot.md`(能力、REST、排障)。
- 不改任务通知的 Webhook 校验。

## 14. 配置

| 变量 | 默认 | 作用 |
|------|------|------|
| `DINGTALK_BOT_ENABLED` | `false` | 总开关 |
| `DINGTALK_BOT_RECONCILE_INTERVAL_MS` | `5000` | 扫表间隔 |
| `APP_DINGTALK_BOT_SECRET` | 空 | 机器人 secret 的 AES-GCM 密钥,不复用飞书那一项 |
| `DINGTALK_OAUTH_CLIENT_ID` / `SECRET` / `REDIRECT_URI` | 空 | 绑定用登录应用 |
| `DINGTALK_PROGRESS_CARD_TEMPLATE_ID` | 空 | 进度卡模板,可被机器人行覆盖 |
| `DINGTALK_QUEUE_CARD_TEMPLATE_ID` | 空 | 排队卡模板 |
| `DINGTALK_BOT_REPLY_MAX_LENGTH` | `2000` | markdown 截断 |
| `DINGTALK_BOT_GROUP_CONTEXT_MAX_ITEMS` | `10` | @ 往来条数 |
| `DINGTALK_BOT_GROUP_CONTEXT_MAX_MINUTES` | `120` | @ 往来时间窗 |

开放平台侧每个机器人:企业内部应用、机器人选 Stream、发布(否则没有 `senderStaffId`)、权限包含企业内机器人发送消息、卡片实例写、上传媒体。登录应用配置与 `REDIRECT_URI` 一致的回调,并有「根据 unionid 获取用户」和通讯录个人信息读。卡片平台发布进度卡和排队卡。联调应用与线上应用分开。

## 15. 测试

`cd backend-ts && npm test`,至少覆盖:

- 归一化:单聊 / 群、@ 空格、富文本图片、引用有正文 / 无正文、丢弃加密 senderId。
- 去重:重复 `msgId` 不跑第二次;`FAILED` 可再认领。
- 未绑定发短链卡;无 staffId 不发绑定卡。
- 私聊 `---`:空闲时只换指针;执行中先取消再换指针;群里的 `---` 当文本。
- 引用不改变 `dingtalk_chat.session_id`。
- 独立文件不触发执行;下一条文字会触发。
- 群上下文标题含「不是完整群聊」,且不含未 @ 消息。
- 排队插队与取消本条;非发送者点按钮被拒绝。
- 进度卡终态更新;恢复时能按 `outTrackId` 找到卡。
- `filterToolsForSession`:钉钉会话没有 `ask_user_questions`、没有飞书工具;飞书会话没有钉钉工具。
- `dingtalk_send_image` / `dingtalk_send_file` 的成功、失败、缺参数,以及展示名。

不新增 Playwright。卡片模板和真实 Stream 用手动联调:发布后的机器人、绑定、私聊、群 @、取消、排队、`---`。

## 16. 实现顺序

1. 迁移、机器人仓储、加密、管理端 API 与页面。开关默认关闭。
2. token、Stream、归一化、去重。能在日志里看到消息。
3. OAuth 绑定、短链、待重放。
4. 会话指针、工作区、文本入站、主动回复。
5. 图片和文件、群 @ 日志与注入。
6. 排队卡、进度卡、取消与重试、网页端同步。
7. 出站图片 / 文件工具、展示名、CHANGELOG 与 mao-cli。

## 17. 风险

| 风险 | 处理 |
|------|------|
| 同一 Client ID 两条 Stream 抢消息 | 单实例;本地用另一个应用 |
| 未发布没有 userid | 日志区分「无 staffId」和「未绑定」;发布后再测绑定 |
| markdown 长度文档不一致 | 先按 2000 截断,接口报过长再降到 500 |
| 卡片回调超过 2 秒会弹回 | 回调只改状态并返回变量 |
| 模板未发布 | 退化为纯文本进度,任务继续 |
| 内容安全拒发 | 失败写进进度卡 |
| 图片 `photoURL` 与 `media_id` 的实际字段 | 实现时对文档,用契约测试固定能发出去的参数 |