# 钉钉机器人通道技术方案 调研见 [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` 的实际字段 | 实现时对文档,用契约测试固定能发出去的参数 |