From 5812a31a436d9acff0c33c0f481efe88cb7e458c Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Thu, 23 Apr 2026 11:30:54 +0800 Subject: [PATCH 1/4] feat(channel/dingtalk): replace Node.js connector with Python Stream Mode plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the legacy `.flocks/plugins/channels/dingtalk/` Node.js connector in favour of a pure-Python `DingTalkChannel` built on the official `dingtalk-stream` SDK (>= 0.20). Inbound runs over the Stream Mode WebSocket; outbound reuses the existing OAPI app-robot send library. * Multi-account stream runner with message gating (require_mention / free_response_chats / mention_patterns / allowed_users) and rich-text / image extraction across SDK versions. * Pre-flight gateway probe surfaces 4xx auth failures (bad clientId/ clientSecret, app revoked, Stream Mode subscription not enabled) so the channel stops retrying unfixable credentials instead of looping endlessly inside the SDK. * `_wait_until_done` raises a transient error when runners are cancelled by a concurrent `plugin.stop()` while `abort_event` is still clear -- fixes a gateway race where rapid restart calls left the channel stuck in "passive mode" with no live stream. * DM `chat_id` is the sender's `staffId` (not the `conversation_id`) so replies route through `/v1.0/robot/oToMessages/batchSend` instead of failing with "robot 不存在" on `/groupMessages/send`. Adds 62 unit tests covering config parsing, payload building, send routing, gating, message extraction, pre-flight error classification, and the gateway-race / DM-routing regressions. Made-with: Cursor --- .../dingtalk-openclaw-connector/.gitignore | 17 - .../dingtalk-openclaw-connector/.npmignore | 4 - .../dingtalk-openclaw-connector/LICENSE | 21 - .../dingtalk-openclaw-connector/README.md | 665 --- .../dingtalk-openclaw-connector/bun.lock | 154 - .../openclaw.plugin.json | 15 - .../dingtalk-openclaw-connector/package.json | 60 - .../dingtalk-openclaw-connector/plugin.ts | 3867 ----------------- .flocks/plugins/channels/dingtalk/dingtalk.py | 368 -- .../plugins/channels/dingtalk/package.json | 1 - .flocks/plugins/channels/dingtalk/runner.ts | 474 -- flocks/channel/builtin/dingtalk/__init__.py | 22 +- flocks/channel/builtin/dingtalk/channel.py | 354 ++ flocks/channel/builtin/dingtalk/stream.py | 714 +++ flocks/channel/registry.py | 7 +- pyproject.toml | 4 +- tests/channel/test_dingtalk.py | 588 ++- uv.lock | 17 +- 18 files changed, 1556 insertions(+), 5796 deletions(-) delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.gitignore delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.npmignore delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/LICENSE delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/README.md delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/bun.lock delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/openclaw.plugin.json delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/package.json delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/plugin.ts delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk.py delete mode 100644 .flocks/plugins/channels/dingtalk/package.json delete mode 100644 .flocks/plugins/channels/dingtalk/runner.ts create mode 100644 flocks/channel/builtin/dingtalk/channel.py create mode 100644 flocks/channel/builtin/dingtalk/stream.py diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.gitignore b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.gitignore deleted file mode 100644 index b22138cdd..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -__pycache__/ -*.py[cod] -*.egg-info/ -dist/ -build/ -.venv/ -.env -*.egg -node_modules/ -package-lock.json - -# IDE -.idea/ - -openclaw/ -.aone_copilot/ -AGENTS.md \ No newline at end of file diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.npmignore b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.npmignore deleted file mode 100644 index b32fe0f90..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.npmignore +++ /dev/null @@ -1,4 +0,0 @@ -.git/ -.claude/ -.env -.gitignore diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/LICENSE b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/LICENSE deleted file mode 100644 index ad00cc544..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 DingTalk Real Team - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/README.md b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/README.md deleted file mode 100644 index 2070fa119..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/README.md +++ /dev/null @@ -1,665 +0,0 @@ -# DingTalk OpenClaw Connector - -以下提供两种方案连接到 [OpenClaw](https://openclaw.ai) Gateway,分别是钉钉机器人和钉钉 DEAP Agent。 - -> 📝 **版本信息**:当前版本 v0.7.5 | [查看变更日志](CHANGELOG.md) | [发布说明](docs/RELEASE_NOTES_V0.7.4.md) | [发布指南](RELEASE.md) - -## 快速导航 - -| 方案 | 名称 | 详情 | -|------|------|------| -| 方案一 | 钉钉机器人集成 | [查看详情](#方案一钉钉机器人集成) | -| 方案二 | 钉钉 DEAP Agent 集成 | [查看详情](#方案二钉钉-deap-agent-集成) | - -# 方案一:钉钉机器人集成 -将钉钉机器人连接到 [OpenClaw](https://openclaw.ai) Gateway,支持 AI Card 流式响应和会话管理。 - -## 特性 - -- ✅ **AI Card 流式响应** - 打字机效果,实时显示 AI 回复 -- ✅ **会话持久化** - 同一用户的多轮对话共享上下文 -- ✅ **会话与记忆隔离** - 按单聊/群聊/群区分 session,不同场景下的对话上下文互不干扰,可配置跨会话记忆共享 -- ✅ **超时自动新会话** - 默认 30 分钟无活动自动开启新对话 -- ✅ **手动新会话** - 发送 `/new` 或 `新会话` 清空对话历史 -- ✅ **图片自动上传** - 本地图片路径自动上传到钉钉 -- ✅ **主动发送消息** - 支持主动给钉钉个人或群发送消息 -- ✅ **富媒体接收** - 支持接收 JPEG/PNG 图片消息,自动下载并传递给视觉模型 -- ✅ **文件附件提取** - 支持解析 .docx、.pdf、纯文本文件(.txt、.md、.json 等)和二进制文件(.xlsx、.pptx、.zip 等) -- ✅ **音频消息支持** - 支持发送音频消息,支持多种格式(mp3、wav、amr、ogg),自动提取音频时长,支持通过标记或文件附件方式发送 -- ✅ **钉钉文档 API** - 支持创建、追加、搜索、列举钉钉文档 -- ✅ **多 Agent 路由** - 支持一个连接器实例连接多个 Agent,多个钉钉机器人可分别绑定到不同 Agent,实现角色分工和专业化服务 -- ✅ **Markdown 表格转换** - 自动将 Markdown 表格转换为钉钉支持的文本格式,提升消息可读性 -- ✅ **异步模式** - 立即回执用户消息,后台处理任务,然后主动推送最终结果作为独立消息(可选) - - -## 架构 - -```mermaid -graph LR - subgraph "钉钉" - A["用户发消息"] --> B["Stream WebSocket"] - E["AI 流式卡片"] --> F["用户看到回复"] - end - - subgraph "Connector" - B --> C["消息处理器"] - C -->|"HTTP SSE"| D["Gateway /v1/chat/completions"] - D -->|"流式 chunk"| C - C -->|"streaming API"| E - end -``` - -## 效果 - -image -image - -## 安装 - -### 1. 安装插件 - -```bash -# 通过 npm 安装(推荐) -openclaw plugins install @dingtalk-real-ai/dingtalk-connector - -# 或通过 Git 安装 -openclaw plugins install https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector.git - -# 升级插件 -openclaw plugins update dingtalk-connector - -# 或本地开发模式 -git clone https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector.git -cd dingtalk-openclaw-connector -npm install -openclaw plugins install -l . -``` - -> **⚠️ 旧版本升级提示:** 如果你之前安装过旧版本的 Clawdbot/Moltbot 或 0.4.0 以下版本的 connector 插件,可能会出现兼容性问题,请参考 [Q: 升级后出现插件加载异常或配置不生效](#q-升级后出现插件加载异常或配置不生效)。 - -### 2. 配置 - -在 `~/.openclaw/openclaw.json` 中添加: - -```json5 -{ - "channels": { - "dingtalk-connector": { - "enabled": true, - "clientId": "dingxxxxxxxxx", // 钉钉 AppKey - "clientSecret": "your_secret_here", // 钉钉 AppSecret - "gatewayToken": "", // 可选:Gateway 认证 token, openclaw.json配置中 gateway.auth.token 的值 - "gatewayPassword": "", // 可选:Gateway 认证 password(与 token 二选一) - "sessionTimeout": 1800000, // ⚠️ 已废弃,请使用 Gateway 的 session.reset.idleMinutes 配置 - "separateSessionByConversation": true, // 可选:是否按单聊/群聊/群区分 session(默认:true) - "groupSessionScope": "group", // 可选:群聊会话隔离策略,group=群共享,group_sender=群内用户独立(默认:group) - "sharedMemoryAcrossConversations": false, // 可选:是否在不同会话间共享记忆;false 时群聊与私聊、不同群记忆隔离(默认:false) - "asyncMode": false, // 可选:异步模式,立即回执用户消息,后台处理并推送结果(默认:false) - "ackText": "🫡 任务已接收" // 可选:异步模式下的回执消息文本(默认:'🫡 任务已接收,处理中...') - } - }, - "gateway": { // gateway通常是已有的节点,配置时注意把http部分追加到已有节点下 - "http": { - "endpoints": { - "chatCompletions": { - "enabled": true - } - } - } - } -} -``` - -或者在 OpenClaw Dashboard 页面配置: - -image - -### 3. 重启 Gateway - -```bash -openclaw gateway restart -``` - -验证: - -```bash -openclaw plugins list # 确认 dingtalk-connector 已加载 -``` - -## 创建钉钉机器人 - -1. 打开 [钉钉开放平台](https://open.dingtalk.com/) -2. 进入 **应用开发** → **企业内部开发** → 创建应用 -3. 添加 **机器人** 能力,消息接收模式选择 **Stream 模式** -4. 开通权限: - - `Card.Streaming.Write` - AI Card 流式响应 - - `Card.Instance.Write` - AI Card 实例写入 - - `qyapi_robot_sendmsg` - 主动发送消息 - - 如需使用文档 API 功能,还需开通文档相关权限 -5. **发布应用**,记录 **AppKey** 和 **AppSecret** - -## 配置参考 - -| 配置项 | 环境变量 | 说明 | -|--------|----------|------| -| `clientId` | `DINGTALK_CLIENT_ID` | 钉钉 AppKey | -| `clientSecret` | `DINGTALK_CLIENT_SECRET` | 钉钉 AppSecret | -| `gatewayToken` | `OPENCLAW_GATEWAY_TOKEN` | Gateway 认证 token(可选) | -| `gatewayPassword` | — | Gateway 认证 password(可选,与 token 二选一) | -| `sessionTimeout` | — | ⚠️ 已废弃,请使用 Gateway 的 [`session.reset.idleMinutes`](https://docs.openclaw.ai/gateway/configuration) 配置 | -| `separateSessionByConversation` | — | 是否按单聊/群聊/群区分 session(默认:true) | -| `groupSessionScope` | — | 群聊会话隔离策略(仅当 separateSessionByConversation=true 时生效):`group`=群共享,`group_sender`=群内用户独立(默认:group) | -| `sharedMemoryAcrossConversations` | — | 是否在不同会话间共享记忆;false 时群聊与私聊、不同群记忆隔离(默认:false) | -| `asyncMode` | — | 异步模式,立即回执用户消息,后台处理并推送结果(默认:false) | -| `ackText` | — | 异步模式下的回执消息文本(默认:'🫡 任务已接收,处理中...') | - -## 会话与记忆隔离 - -连接器支持按单聊、群聊、不同群分别维护独立会话和记忆,确保同一用户在不同场景下的对话上下文互不干扰。 - -### 会话隔离(separateSessionByConversation) - -- **默认开启**(`true`):单聊、群聊、不同群各自拥有独立的 session -- **关闭**(`false`):按用户维度维护 session,不区分单聊/群聊(兼容旧行为) - -### 群聊会话隔离(groupSessionScope) - -仅当 `separateSessionByConversation=true` 时生效: - -- **`group`**(默认):整个群共享一个会话,群内所有用户共用同一个对话上下文 -- **`group_sender`**:群内每个用户独立会话,不同用户的对话上下文互不干扰 - -### 记忆隔离(sharedMemoryAcrossConversations) - -- **默认关闭**(`false`):不同群聊、群聊与私聊之间的记忆隔离,AI 不会混淆不同场景下的对话历史 -- **开启**(`true`):单 Agent 场景下,同一用户在不同会话间共享记忆 - -### 适用场景 - -- ✅ 同一机器人在多个群中服务,希望每个群的对话互不干扰 -- ✅ 用户既在私聊也在群聊中使用机器人,希望私聊与群聊上下文分离 -- ✅ 群内所有成员共享对话上下文(默认 `groupSessionScope: "group"`) -- ✅ 群内每个用户独立对话(设置 `groupSessionScope: "group_sender"`) -- ✅ 需要跨会话共享记忆时,可设置 `sharedMemoryAcrossConversations: true` - -## 异步模式 - -异步模式允许连接器立即回执用户消息,然后在后台处理任务,最后主动推送最终结果作为独立消息。这种模式特别适合处理耗时较长的任务,可以给用户更好的交互体验。 - -### 启用异步模式 - -在配置中设置 `asyncMode: true`: - -```json5 -{ - "channels": { - "dingtalk-connector": { - "enabled": true, - "clientId": "dingxxxxxxxxx", - "clientSecret": "your_secret_here", - "asyncMode": true, // 启用异步模式 - "ackText": "🫡 任务已接收" // 可选:自定义回执消息 - } - } -} -``` - -### 工作流程 - -1. **立即回执** - 用户发送消息后,连接器立即发送回执消息(默认:`🫡 任务已接收,处理中...`) -2. **后台处理** - 连接器在后台调用 Gateway 处理任务,支持文件附件和图片 -3. **推送结果** - 处理完成后,连接器主动推送最终结果作为独立消息 - -### 适用场景 - -- ✅ 处理耗时较长的任务(如文档分析、代码生成等) -- ✅ 需要给用户即时反馈的场景 -- ✅ 希望将处理过程和结果分离的场景 - -### 注意事项 - -- 异步模式下不支持 AI Card 流式响应(因为结果通过主动推送发送) -- 异步模式支持文件附件和图片处理 -- 错误信息也会通过主动推送发送给用户 - -## 多 Agent 配置 - -钉钉 Connector 支持多 Agent 模式,可以配置多个钉钉机器人连接到不同的 Agent,实现角色分工和专业化服务。 - -### 核心配置 - -在 `~/.openclaw/openclaw.json` 中配置多个钉钉账号和 Agent 绑定: - -```json5 -{ - "channels": { - "dingtalk-connector": { - "enabled": true, - "accounts": { - "bot1": { - "enabled": true, - "clientId": "ding_bot1_app_key", - "clientSecret": "bot1_secret" - }, - "bot2": { - "enabled": true, - "clientId": "ding_bot2_app_key", - "clientSecret": "bot2_secret" - } - } - } - }, - "bindings": [ - { - "agentId": "ding-bot1", - "match": { - "channel": "dingtalk-connector", - "accountId": "bot1" - } - }, - { - "agentId": "ding-bot2", - "match": { - "channel": "dingtalk-connector", - "accountId": "bot2" - } - } - ] -} -``` - -### 基于单聊/群聊的路由(peer.kind) - -连接器支持根据会话类型(单聊/群聊)将消息路由到不同的 Agent。这对于以下场景非常有用: - -- **安全隔离**:群聊使用受限功能的 Agent,单聊使用完整功能的 Agent -- **多角色支持**:不同用户或会话类型分配不同的 Agent -- **成本优化**:普通用户路由到低成本模型,VIP 用户使用高端模型 - -#### 配置示例 - -```json5 -{ - "bindings": [ - // 场景1:特定用户的单聊 → main agent(完整功能) - { - "agentId": "main", - "match": { - "channel": "dingtalk-connector", - "peer": { - "kind": "direct", - "id": "YOUR_VIP_USER_ID" - } - } - }, - // 场景2:所有群聊 → guest agent(受限功能) - { - "agentId": "guest", - "match": { - "channel": "dingtalk-connector", - "peer": { - "kind": "group", - "id": "*" - } - } - }, - // 场景3:其他单聊 → guest agent(受限功能) - { - "agentId": "guest", - "match": { - "channel": "dingtalk-connector", - "peer": { - "kind": "direct", - "id": "*" - } - } - } - ] -} -``` - -#### peer.kind 配置说明 - -| 字段 | 类型 | 说明 | -|------|------|------| -| `peer.kind` | `'direct'` \| `'group'` | 会话类型:`direct` 表示单聊,`group` 表示群聊 | -| `peer.id` | `string` | 发送者 ID(单聊)或 `*` 通配符匹配所有 | - -#### 匹配优先级 - -bindings 按以下优先级匹配(从高到低): - -1. **peer.kind + peer.id 精确匹配**:指定会话类型和具体用户 ID -2. **peer.kind + peer.id='*' 通配匹配**:指定会话类型,匹配所有用户 -3. **仅 peer.kind 匹配**:只指定会话类型(无 peer.id) -4. **accountId 匹配**:按钉钉账号路由 -5. **channel 匹配**:仅指定 channel -6. **默认 fallback**:使用 `main` agent - -### 官方文档 - -详细的配置指南和架构说明,请参考 OpenClaw 官方文档: - -- [OpenClaw 多 Agent 架构配置指南](https://gist.github.com/smallnest/c5c13482740fd179e40070e620f66a52) - - -## 会话命令 - -用户可以发送以下命令开启新会话(清空对话历史): - -- `/new`、`/reset`、`/clear` -- `新会话`、`重新开始`、`清空对话` - -## 富媒体接收 - -### 图片消息支持 - -连接器支持接收和处理钉钉中的图片消息: - -- **JPEG 图片** - 直接发送的 JPEG 图片会自动下载到 `~/.openclaw/workspace/media/inbound/` 目录 -- **PNG 图片** - 富文本消息中包含的 PNG 图片会自动提取 URL 和 downloadCode 并下载 -- **视觉模型集成** - 下载的图片会自动传递给视觉模型,AI 可以识别和分析图片内容 - -### 媒体文件存储 - -所有接收的媒体文件会保存在: - -```bash -~/.openclaw/workspace/media/inbound/ -``` - -文件命名格式:`openclaw-media-{timestamp}.{ext}` - -查看媒体目录: - -```bash -ls -la ~/.openclaw/workspace/media/inbound/ -``` - -## 文件附件提取 - -连接器支持自动提取和处理钉钉消息中的文件附件: - -### 支持的文件类型 - -| 文件类型 | 处理方式 | 说明 | -|---------|---------|------| -| `.docx` | 通过 `mammoth` 解析 | 提取 Word 文档中的文本内容,注入到 AI 上下文 | -| `.pdf` | 通过 `pdf-parse` 解析 | 提取 PDF 文档中的文本内容,注入到 AI 上下文 | -| `.txt`、`.md`、`.json` 等 | 直接读取 | 纯文本文件内容直接读取并注入到消息中 | -| `.xlsx`、`.pptx`、`.zip` 等 | 保存到磁盘 | 二进制文件保存到磁盘,文件路径和名称会在消息中报告 | - -### 使用方式 - -直接在钉钉中发送文件附件,连接器会自动: -1. 下载文件到本地 -2. 根据文件类型进行解析或保存 -3. 将文本内容注入到 AI 对话上下文中 - -## 钉钉文档 API - -连接器提供了丰富的钉钉文档操作能力,可在 OpenClaw Agent 中调用: - -### 创建文档 - -```javascript -dingtalk-connector.docs.create({ - spaceId: "your-space-id", - title: "测试文档", - content: "# 测试内容" -}) -``` - -### 追加内容 - -```javascript -dingtalk-connector.docs.append({ - docId: "your-doc-id", - markdownContent: "\n## 追加的内容" -}) -``` - -### 搜索文档 - -```javascript -dingtalk-connector.docs.search({ - keyword: "搜索关键词" -}) -``` - -### 列举文档 - -```javascript -dingtalk-connector.docs.list({ - spaceId: "your-space-id" -}) -``` - -## 多 Agent 路由支持 - -连接器支持同时连接多个 Agent,实现多 Agent 会话隔离: - -- **独立会话空间** - 每个 Agent 拥有独立的会话上下文,互不干扰 -- **灵活路由** - 可根据不同场景将请求路由到不同的 Agent -- **向后兼容** - 单 Agent 场景下功能完全兼容,无需额外配置 - -## 项目结构 - -``` -dingtalk-openclaw-connector/ -├── plugin.ts # 插件入口 -├── openclaw.plugin.json # 插件清单 -├── package.json # npm 依赖 -└── LICENSE -``` - -## 常见问题 - -### Q: 出现 405 错误 - -image - -需要在 `~/.openclaw/openclaw.json` 中启用 chatCompletions 端点: - -```json5 -{ - "gateway": { // gateway通常是已有的节点,配置时注意把http部分追加到已有节点下 - "http": { - "endpoints": { - "chatCompletions": { - "enabled": true - } - } - } - } -} -``` - -### Q: 出现 401 错误 - -image - -检查 `~/.openclaw/openclaw.json` 中的gateway.auth鉴权的 token/password 是否正确: - -image - -### Q: 钉钉机器人无响应 - -1. 确认 Gateway 正在运行:`curl http://127.0.0.1:18789/health` -2. 确认机器人配置为 **Stream 模式**(非 Webhook) -3. 确认 AppKey/AppSecret 正确 - -### Q: AI Card 不显示,只有纯文本 - -需要开通权限 `Card.Streaming.Write` 和 `Card.Instance.Write`,并重新发布应用。 - -### Q: 升级后出现插件加载异常或配置不生效 - -由于官方两次更名(Clawdbot → Moltbot → OpenClaw),旧版本(0.4.0 以下)的 connector 插件可能与新版本不兼容。建议按以下步骤处理: - -1. 先检查 `~/.openclaw/openclaw.json`(或旧版的 `~/.clawdbot/clawdbot.json`、`~/.moltbot/moltbot.json`),如果其中存在 dingtalk 相关的 JSON 节点(如 `channels.dingtalk`、`plugins.entries.dingtalk` 等),请将这些节点全部删除。 - -2. 然后清除旧插件并重新安装: - -```bash -rm -rf ~/.clawdbot/extensions/dingtalk-connector -rm -rf ~/.moltbot/extensions/dingtalk-connector -rm -rf ~/.openclaw/extensions/dingtalk-connector -openclaw plugins install @dingtalk-real-ai/dingtalk-connector -``` - -### Q: 图片不显示 - -1. 确认 `enableMediaUpload: true`(默认开启) -2. 检查日志 `[DingTalk][Media]` 相关输出 -3. 确认钉钉应用有图片上传权限 - -### Q: 图片消息无法识别 - -1. 检查图片是否成功下载到 `~/.openclaw/workspace/media/inbound/` 目录 -2. 确认 Gateway 配置的模型支持视觉能力(vision model) -3. 查看日志中是否有图片下载或处理的错误信息 - -### Q: 文件附件无法解析 - -1. **Word 文档(.docx)**:确认已安装 `mammoth` 依赖包 -2. **PDF 文档**:确认已安装 `pdf-parse` 依赖包 -3. 检查文件是否成功下载,查看日志中的文件处理信息 -4. 对于不支持的二进制文件,会保存到磁盘并在消息中报告文件路径 - -### Q: 钉钉文档 API 调用失败 - -1. 确认钉钉应用已开通文档相关权限 -2. 检查 `spaceId`、`docId` 等参数是否正确 -3. 确认 API 调用时的认证信息(AppKey/AppSecret)有效 -4. 注意:读取文档功能需要 MCP 提供相应的 tool,当前版本暂不支持 - -### Q: 多 Agent 路由如何配置 - -多 Agent 路由功能会自动处理,无需额外配置。连接器会根据配置自动管理多个 Agent 的会话隔离。如需自定义路由逻辑,请参考插件源码中的路由实现。 - -## 依赖 - -| 包 | 用途 | -|----|------| -| `dingtalk-stream` | 钉钉 Stream 协议客户端 | -| `axios` | HTTP 客户端 | -| `mammoth` | Word 文档(.docx)解析 | -| `pdf-parse` | PDF 文档解析 | - -# 方案二:钉钉 DEAP Agent 集成 - -通过将钉钉 [DEAP](https://deap.dingtalk.com) Agent 与 [OpenClaw](https://openclaw.ai) Gateway 连接,实现自然语言驱动的本地设备操作能力。 - -## 核心功能 - -- ✅ **自然语言交互** - 用户在钉钉对话框中输入自然语言指令(如"帮我查找桌面上的 PDF 文件"),Agent 将自动解析并执行相应操作 -- ✅ **内网穿透机制** - 专为本地设备无公网 IP 场景设计,通过 Connector 客户端建立稳定的内外网通信隧道 -- ✅ **跨平台兼容** - 提供 Windows、macOS 和 Linux 系统的原生二进制执行文件,确保各平台下的顺畅运行 - -## 系统架构 - -该方案采用分层架构模式,包含三个核心组件: - -1. **OpenClaw Gateway** - 部署于本地设备,提供标准化 HTTP 接口,负责接收并处理来自云端的操作指令,调动 OpenClaw 引擎执行具体任务 -2. **DingTalk OpenClaw Connector** - 运行于本地环境,构建本地与云端的通信隧道,解决内网设备无公网 IP 的问题 -3. **DingTalk DEAP MCP** - 作为 DEAP Agent 的扩展能力模块,负责将用户自然语言请求经由云端隧道转发至 OpenClaw Gateway - -```mermaid -graph LR - subgraph "钉钉 App" - A["用户与 Agent 对话"] --> B["DEAP Agent"] - end - - subgraph "本地环境" - D["DingTalk OpenClaw Connector"] --> C["OpenClaw Gateway"] - C --> E["PC 操作执行"] - end - - B -.-> D -``` - -## 实施指南 - -### 第一步:部署本地环境 - -确认本地设备已成功安装并启动 OpenClaw Gateway,默认监听地址为 `127.0.0.1:18789`: - -```bash -openclaw gateway start -``` - -#### 配置 Gateway 参数 - -1. 访问 [配置页面](http://127.0.0.1:18789/config) -2. 在 **Auth 标签页** 中设置 Gateway Token 并妥善保存: - - Gateway Auth 配置界面 - -3. 切换至 **Http 标签页**,启用 `OpenAI Chat Completions Endpoint` 功能: - - Gateway Http 配置界面 - -4. 点击右上角 `Save` 按钮完成配置保存 - -### 第二步:获取必要参数 - -#### 获取 corpId - -登录 [钉钉开发者平台](https://open-dev.dingtalk.com) 查看企业 CorpId: - -钉钉开发者平台获取 corpId - -#### 获取 apiKey - -登录 [钉钉 DEAP 平台](https://deap.dingtalk.com),在 **安全与权限** → **API-Key 管理** 页面创建新的 API Key: - -钉钉 DEAP 平台 API-Key 管理 - -### 第三步:启动 Connector 客户端 - -1. 从 [Releases](https://github.com/hoskii/dingtalk-openclaw-connector/releases/tag/v0.0.1) 页面下载适配您操作系统的安装包 -2. 解压并运行 Connector(以 macOS 为例): - - ```bash - unzip connector-mac.zip - ./connector-darwin -deapCorpId YOUR_CORP_ID -deapApiKey YOUR_API_KEY - ``` - -### 第四步:配置 DEAP Agent - -1. 登录 [钉钉 DEAP 平台](https://deap.dingtalk.com),创建新的智能体: - - 新建智能体界面 - -2. 在技能管理页面,搜索并集成 OpenClaw 技能: - - 添加 OpenClaw 技能 - -3. 配置技能参数: - - | 参数 | 来源 | 说明 | - |------|------|------| - | apikey | 第二步获取 | DEAP 平台 API Key | - | apihost | 默认值 | 通常为 `127.0.0.1:18789`,在Windows环境下可能需要配置为 `localhost:18789` 才能正常工作 | - | gatewayToken | 第一步获取 | Gateway 配置的认证令牌 | - - 配置 OpenClaw 技能参数 - -4. 发布 Agent: - - 发布 Agent - -### 第五步:开始使用 - -1. 在钉钉 App 中搜索并找到您创建的 Agent: - - 搜索 Agent - -2. 开始自然语言对话体验: - - 与 Agent 对话 - -## License - -[MIT](LICENSE) diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/bun.lock b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/bun.lock deleted file mode 100644 index e566526bc..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/bun.lock +++ /dev/null @@ -1,154 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "@dingtalk-real-ai/dingtalk-connector", - "dependencies": { - "@ffmpeg-installer/ffmpeg": "^1.1.0", - "axios": "^1.6.0", - "dingtalk-stream": "^2.1.4", - "fluent-ffmpeg": "^2.1.3", - "mammoth": "^1.8.0", - "pdf-parse": "^1.1.1", - }, - }, - }, - "packages": { - "@ffmpeg-installer/darwin-arm64": ["@ffmpeg-installer/darwin-arm64@4.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA=="], - - "@ffmpeg-installer/darwin-x64": ["@ffmpeg-installer/darwin-x64@4.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw=="], - - "@ffmpeg-installer/ffmpeg": ["@ffmpeg-installer/ffmpeg@1.1.0", "", { "optionalDependencies": { "@ffmpeg-installer/darwin-arm64": "4.1.5", "@ffmpeg-installer/darwin-x64": "4.1.0", "@ffmpeg-installer/linux-arm": "4.1.3", "@ffmpeg-installer/linux-arm64": "4.1.4", "@ffmpeg-installer/linux-ia32": "4.1.0", "@ffmpeg-installer/linux-x64": "4.1.0", "@ffmpeg-installer/win32-ia32": "4.1.0", "@ffmpeg-installer/win32-x64": "4.1.0" } }, "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg=="], - - "@ffmpeg-installer/linux-arm": ["@ffmpeg-installer/linux-arm@4.1.3", "", { "os": "linux", "cpu": "arm" }, "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg=="], - - "@ffmpeg-installer/linux-arm64": ["@ffmpeg-installer/linux-arm64@4.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg=="], - - "@ffmpeg-installer/linux-ia32": ["@ffmpeg-installer/linux-ia32@4.1.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ=="], - - "@ffmpeg-installer/linux-x64": ["@ffmpeg-installer/linux-x64@4.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A=="], - - "@ffmpeg-installer/win32-ia32": ["@ffmpeg-installer/win32-ia32@4.1.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw=="], - - "@ffmpeg-installer/win32-x64": ["@ffmpeg-installer/win32-x64@4.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg=="], - - "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], - - "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - - "async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="], - - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - - "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], - - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - - "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - - "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], - - "dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="], - - "dingtalk-stream": ["dingtalk-stream@2.1.4", "", { "dependencies": { "axios": "^1.4.0", "debug": "^4.3.4", "ws": "^8.13.0" } }, "sha512-rgQbXLGWfASuB9onFcqXTnRSj4ZotimhBOnzrB4kS19AaU9lshXiuofs1GAYcKh5uzPWCAuEs3tMtiadTQWP4A=="], - - "duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="], - - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - - "fluent-ffmpeg": ["fluent-ffmpeg@2.1.3", "", { "dependencies": { "async": "^0.2.9", "which": "^1.1.1" } }, "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q=="], - - "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], - - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - - "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], - - "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], - - "lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="], - - "mammoth": ["mammoth@1.11.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "node-ensure": ["node-ensure@0.0.0", "", {}, "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw=="], - - "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], - - "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], - - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - - "pdf-parse": ["pdf-parse@1.1.4", "", { "dependencies": { "node-ensure": "^0.0.0" } }, "sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ=="], - - "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], - - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], - - "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - - "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - - "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], - - "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - - "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - - "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], - - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - - "which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], - - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - - "xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="], - } -} diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/openclaw.plugin.json b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/openclaw.plugin.json deleted file mode 100644 index 29a247e1e..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/openclaw.plugin.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "dingtalk-connector", - "name": "DingTalk Channel", - "version": "0.7.4", - "description": "DingTalk (钉钉) messaging channel via Stream mode with AI Card streaming", - "author": "DingTalk Real Team", - "channels": ["dingtalk-connector"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { "type": "boolean", "default": true } - } - } -} diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/package.json b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/package.json deleted file mode 100644 index 8c54b9af2..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/package.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "name": "@dingtalk-real-ai/dingtalk-connector", - "version": "0.7.5", - "description": "DingTalk (钉钉) channel connector — Stream mode with AI Card streaming", - "main": "plugin.ts", - "type": "module", - "scripts": { - "build": "echo 'No build needed - jiti loads TS at runtime'", - "lint": "echo 'Lint check skipped'", - "lint:fix": "echo 'Lint fix skipped'", - "test": "echo 'Tests skipped'", - "test:watch": "echo 'Tests skipped'", - "start:runner": "tsx ../runner.ts", - "type-check": "npx tsc --noEmit", - "version:check": "echo 'Version check skipped'", - "release:prepare": "echo 'Release prepare skipped'", - "release:publish": "npm publish --access public", - "release:verify": "npm view @dingtalk-real-ai/dingtalk-connector version", - "clean": "rm -rf node_modules package-lock.json", - "install:fresh": "npm run clean && npm install", - "dev": "echo 'Run: openclaw start'", - "validate": "npm run lint && npm run type-check && npm run version:check" - }, - "keywords": [ - "dingtalk", - "channel", - "stream", - "ai-card", - "connector" - ], - "author": "DingTalk Real Team", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector.git" - }, - "homepage": "https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector#readme", - "bugs": "https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues", - "publishConfig": { - "access": "public" - }, - "dependencies": { - "@ffmpeg-installer/ffmpeg": "^1.1.0", - "axios": "1.14.0", - "dingtalk-stream": "^2.1.4", - "fluent-ffmpeg": "^2.1.3", - "mammoth": "^1.8.0", - "pdf-parse": "^1.1.1", - "tsx": "^4.20.5" - }, - "openclaw": { - "extensions": [ - "./plugin.ts" - ], - "channels": [ - "dingtalk-connector" - ], - "installDependencies": true - } -} diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/plugin.ts b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/plugin.ts deleted file mode 100644 index 51ca41755..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/plugin.ts +++ /dev/null @@ -1,3867 +0,0 @@ -/** - * DingTalk Channel Plugin for Moltbot - * - * 通过钉钉 Stream 模式连接,支持 AI Card 流式响应。 - * 完整接入 Moltbot 消息处理管道。 - */ - -import { DWClient, TOPIC_ROBOT } from 'dingtalk-stream'; -import axios from 'axios'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import type { ClawdbotPluginApi, PluginRuntime, ClawdbotConfig } from 'clawdbot/plugin-sdk'; - -// ============ 常量 ============ - -export const id = 'dingtalk-connector'; - -/** 默认账号 ID,用于标记单账号模式(无 accounts 配置)时的内部标识,映射到 'main' agent */ -const DEFAULT_ACCOUNT_ID = '__default__'; - -let runtime: PluginRuntime | null = null; - -function getRuntime(): PluginRuntime { - if (!runtime) throw new Error('DingTalk runtime not initialized'); - return runtime; -} - -// ============ Session 管理 ============ - -/** 用户会话状态:记录最后活跃时间和当前 session 标识 */ -interface UserSession { - lastActivity: number; - sessionId: string; // 格式: dingtalk-connector: 或 dingtalk-connector:: -} - -/** 用户会话缓存 Map */ -const userSessions = new Map(); - -/** 消息去重缓存 Map - 防止同一消息被重复处理 */ -const processedMessages = new Map(); - -/** 消息去重缓存过期时间(5分钟) */ -const MESSAGE_DEDUP_TTL = 5 * 60 * 1000; - -/** 清理过期的消息去重缓存 */ -function cleanupProcessedMessages(): void { - const now = Date.now(); - for (const [msgId, timestamp] of processedMessages.entries()) { - if (now - timestamp > MESSAGE_DEDUP_TTL) { - processedMessages.delete(msgId); - } - } -} - -/** 检查消息是否已处理过(去重) */ -function isMessageProcessed(messageId: string): boolean { - if (!messageId) return false; - return processedMessages.has(messageId); -} - -/** 标记消息为已处理 */ -function markMessageProcessed(messageId: string): void { - if (!messageId) return; - processedMessages.set(messageId, Date.now()); - // 定期清理(每处理100条消息清理一次) - if (processedMessages.size >= 100) { - cleanupProcessedMessages(); - } -} - -/** 新会话触发命令 */ -const NEW_SESSION_COMMANDS = ['/new', '/reset', '/clear', '新会话', '重新开始', '清空对话']; - -/** 检查消息是否是新会话命令 */ -function isNewSessionCommand(text: string): boolean { - const trimmed = text.trim().toLowerCase(); - return NEW_SESSION_COMMANDS.some(cmd => trimmed === cmd.toLowerCase()); -} - -/** - * OpenClaw 标准会话上下文 - * 遵循 OpenClaw session.dmScope 机制,让 Gateway 根据配置自动处理会话隔离 - */ -interface SessionContext { - channel: 'dingtalk-connector'; - accountId: string; - chatType: 'direct' | 'group'; - peerId: string; - conversationId?: string; - senderName?: string; - groupSubject?: string; -} - -/** - * 构建 OpenClaw 标准会话上下文 - * 遵循 OpenClaw session.dmScope 机制,让 Gateway 根据配置自动处理会话隔离 - * - * @param separateSessionByConversation - 是否按单聊/群聊/群区分 session(默认 true) - * - true: 单聊、群聊、不同群各自拥有独立的 session - * - false: 按用户维度维护 session,不区分单聊/群聊(兼容旧行为) - * @param groupSessionScope - 群聊会话隔离策略(仅当 separateSessionByConversation=true 时生效) - * - 'group': 整个群共享一个会话(默认) - * - 'group_sender': 群内每个用户独立会话 - */ -function buildSessionContext(params: { - accountId: string; - senderId: string; - senderName?: string; - conversationType: string; - conversationId?: string; - groupSubject?: string; - separateSessionByConversation?: boolean; - groupSessionScope?: 'group' | 'group_sender'; -}): SessionContext { - const { accountId, senderId, senderName, conversationType, conversationId, groupSubject, separateSessionByConversation, groupSessionScope } = params; - const isDirect = conversationType === '1'; - - // separateSessionByConversation=false 时,不区分单聊/群聊,按用户维度维护 session - if (separateSessionByConversation === false) { - return { - channel: 'dingtalk-connector', - accountId, - chatType: isDirect ? 'direct' : 'group', - peerId: senderId, // 只用 senderId,不区分会话 - senderName, - }; - } - - // 以下是 separateSessionByConversation=true(默认)的逻辑 - if (isDirect) { - // 单聊:peerId 为发送者 ID,由 OpenClaw Gateway 根据 dmScope 配置处理 - return { - channel: 'dingtalk-connector', - accountId, - chatType: 'direct', - peerId: senderId, - senderName, - }; - } - - // 群聊:根据 groupSessionScope 配置决定会话隔离策略 - if (groupSessionScope === 'group_sender') { - // 群内每个用户独立会话 - return { - channel: 'dingtalk-connector', - accountId, - chatType: 'group', - peerId: `${conversationId}:${senderId}`, - conversationId, - senderName, - groupSubject, - }; - } - - // 默认:整个群共享一个会话 - return { - channel: 'dingtalk-connector', - accountId, - chatType: 'group', - peerId: conversationId || senderId, - conversationId, - senderName, - groupSubject, - }; -} - -// ============ Access Token 缓存 ============ - -let accessToken: string | null = null; -let accessTokenExpiry = 0; - -async function getAccessToken(config: any): Promise { - const now = Date.now(); - if (accessToken && accessTokenExpiry > now + 60_000) { - return accessToken; - } - - const response = await axios.post('https://api.dingtalk.com/v1.0/oauth2/accessToken', { - appKey: config.clientId, - appSecret: config.clientSecret, - }); - - accessToken = response.data.accessToken; - accessTokenExpiry = now + (response.data.expireIn * 1000); - return accessToken!; -} - -// ============ 配置工具 ============ - -function getConfig(cfg: ClawdbotConfig) { - return (cfg?.channels as any)?.['dingtalk-connector'] || {}; -} - -function isConfigured(cfg: ClawdbotConfig): boolean { - const config = getConfig(cfg); - return Boolean(config.clientId && config.clientSecret); -} - -// ============ 钉钉图片上传 ============ - -async function getOapiAccessToken(config: any): Promise { - try { - const resp = await axios.get('https://oapi.dingtalk.com/gettoken', { - params: { appkey: config.clientId, appsecret: config.clientSecret }, - }); - if (resp.data?.errcode === 0) return resp.data.access_token; - return null; - } catch { - return null; - } -} - -/** staffId → unionId 缓存 */ -const unionIdCache = new Map(); - -/** - * 通过 oapi 旧版接口将 staffId 转换为 unionId - */ -async function getUnionId(staffId: string, config: any, log?: any): Promise { - const cached = unionIdCache.get(staffId); - if (cached) return cached; - - try { - const token = await getOapiAccessToken(config); - if (!token) { - log?.error?.('[DingTalk] getUnionId: 无法获取 oapi access_token'); - return null; - } - const resp = await axios.get(`${DINGTALK_OAPI}/user/get`, { - params: { access_token: token, userid: staffId }, - timeout: 10_000, - }); - const unionId = resp.data?.unionid; - if (unionId) { - unionIdCache.set(staffId, unionId); - log?.info?.(`[DingTalk] getUnionId: ${staffId} → ${unionId}`); - return unionId; - } - log?.error?.(`[DingTalk] getUnionId: 响应中无 unionid 字段: ${JSON.stringify(resp.data)}`); - return null; - } catch (err: any) { - log?.error?.(`[DingTalk] getUnionId 失败: ${err.message}`); - return null; - } -} - -function buildMediaSystemPrompt(): string { - return `## 钉钉图片和文件显示规则 - -你正在钉钉中与用户对话。 - -### 一、图片显示 - -显示图片时,直接使用本地文件路径,系统会自动上传处理。 - -**正确方式**: -\`\`\`markdown -![描述](file:///path/to/image.jpg) -![描述](/tmp/screenshot.png) -![描述](/Users/xxx/photo.jpg) -\`\`\` - -**禁止**: -- 不要自己执行 curl 上传 -- 不要猜测或构造 URL -- **不要对路径进行转义(如使用反斜杠 \\ )** - -直接输出本地路径即可,系统会自动上传到钉钉。 - -### 二、视频分享 - -**何时分享视频**: -- ✅ 用户明确要求**分享、发送、上传**视频时 -- ❌ 仅生成视频保存到本地时,**不需要**分享 - -**视频标记格式**: -当需要分享视频时,在回复**末尾**添加: - -\`\`\` -[DINGTALK_VIDEO]{"path":"<本地视频路径>"}[/DINGTALK_VIDEO] -\`\`\` - -**支持格式**:mp4(最大 20MB) - -**重要**: -- 视频大小不得超过 20MB,超过限制时告知用户 -- 仅支持 mp4 格式 -- 系统会自动提取视频时长、分辨率并生成封面 - -### 三、音频分享 - -**何时分享音频**: -- ✅ 用户明确要求**分享、发送、上传**音频/语音文件时 -- ❌ 仅生成音频保存到本地时,**不需要**分享 - -**音频标记格式**: -当需要分享音频时,在回复**末尾**添加: - -\`\`\` -[DINGTALK_AUDIO]{"path":"<本地音频路径>"}[/DINGTALK_AUDIO] -\`\`\` - -**支持格式**:ogg、amr(最大 20MB) - -**重要**: -- 音频大小不得超过 20MB,超过限制时告知用户 -- 系统会自动提取音频时长 - -### 四、文件分享 - -**何时分享文件**: -- ✅ 用户明确要求**分享、发送、上传**文件时 -- ❌ 仅生成文件保存到本地时,**不需要**分享 - -**文件标记格式**: -当需要分享文件时,在回复**末尾**添加: - -\`\`\` -[DINGTALK_FILE]{"path":"<本地文件路径>","fileName":"<文件名>","fileType":"<扩展名>"}[/DINGTALK_FILE] -\`\`\` - -**支持的文件类型**:几乎所有常见格式 - -**重要**:文件大小不得超过 20MB,超过限制时告知用户文件过大。`; -} - -// ============ 图片后处理:自动上传本地图片到钉钉 ============ - -/** - * 匹配 markdown 图片中的本地文件路径(跨平台): - * - ![alt](file:///path/to/image.jpg) - * - ![alt](MEDIA:/var/folders/xxx.jpg) - * - ![alt](attachment:///path.jpg) - * macOS: - * - ![alt](/tmp/xxx.jpg) - * - ![alt](/var/folders/xxx.jpg) - * - ![alt](/Users/xxx/photo.jpg) - * Linux: - * - ![alt](/home/user/photo.jpg) - * - ![alt](/root/photo.jpg) - * Windows: - * - ![alt](C:\Users\xxx\photo.jpg) - * - ![alt](C:/Users/xxx/photo.jpg) - */ -const LOCAL_IMAGE_RE = /!\[([^\]]*)\]\(((?:file:\/\/\/|MEDIA:|attachment:\/\/\/)[^)]+|\/(?:tmp|var|private|Users|home|root)[^)]+|[A-Za-z]:[\\/ ][^)]+)\)/g; - -/** 图片文件扩展名 */ -const IMAGE_EXTENSIONS = /\.(png|jpg|jpeg|gif|bmp|webp|tiff|svg)$/i; - -/** - * 匹配纯文本中的本地图片路径(不在 markdown 图片语法中,跨平台): - * macOS: - * - `/var/folders/.../screenshot.png` - * - `/tmp/image.jpg` - * - `/Users/xxx/photo.png` - * Linux: - * - `/home/user/photo.png` - * - `/root/photo.png` - * Windows: - * - `C:\Users\xxx\photo.png` - * - `C:/temp/image.jpg` - * 支持 backtick 包裹: `path` - */ -const BARE_IMAGE_PATH_RE = /`?((?:\/(?:tmp|var|private|Users|home|root)\/[^\s`'",)]+|[A-Za-z]:[\\/][^\s`'",)]+)\.(?:png|jpg|jpeg|gif|bmp|webp))`?/gi; - -/** 去掉 file:// / MEDIA: / attachment:// 前缀,得到实际的绝对路径 */ -function toLocalPath(raw: string): string { - let path = raw; - if (path.startsWith('file://')) path = path.replace('file://', ''); - else if (path.startsWith('MEDIA:')) path = path.replace('MEDIA:', ''); - else if (path.startsWith('attachment://')) path = path.replace('attachment://', ''); - - // 解码 URL 编码的路径(如中文字符 %E5%9B%BE → 图) - try { - path = decodeURIComponent(path); - } catch { - // 解码失败则保持原样 - } - return path; -} - -/** - * 通用媒体文件上传函数 - * @param filePath 文件路径 - * @param mediaType 媒体类型:image, file, video, voice - * @param oapiToken 钉钉 access_token - * @param maxSize 最大文件大小(字节),默认 20MB - * @param log 日志对象 - * @returns media_id 或 null - */ -async function uploadMediaToDingTalk( - filePath: string, - mediaType: 'image' | 'file' | 'video' | 'voice', - oapiToken: string, - maxSize: number = 20 * 1024 * 1024, - log?: any, -): Promise { - try { - const fs = await import('fs'); - const path = await import('path'); - const FormData = (await import('form-data')).default; - - const absPath = toLocalPath(filePath); - if (!fs.existsSync(absPath)) { - log?.warn?.(`[DingTalk][${mediaType}] 文件不存在: ${absPath}`); - return null; - } - - // 检查文件大小 - const stats = fs.statSync(absPath); - const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2); - - if (stats.size > maxSize) { - const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(0); - log?.warn?.(`[DingTalk][${mediaType}] 文件过大: ${absPath}, 大小: ${fileSizeMB}MB, 超过限制 ${maxSizeMB}MB`); - return null; - } - - const form = new FormData(); - form.append('media', fs.createReadStream(absPath), { - filename: path.basename(absPath), - contentType: mediaType === 'image' ? 'image/jpeg' : 'application/octet-stream', - }); - - log?.info?.(`[DingTalk][${mediaType}] 上传文件: ${absPath} (${fileSizeMB}MB)`); - const resp = await axios.post( - `https://oapi.dingtalk.com/media/upload?access_token=${oapiToken}&type=${mediaType}`, - form, - { headers: form.getHeaders(), timeout: 60_000 }, - ); - - const mediaId = resp.data?.media_id; - if (mediaId) { - log?.info?.(`[DingTalk][${mediaType}] 上传成功: media_id=${mediaId}`); - return mediaId; - } - log?.warn?.(`[DingTalk][${mediaType}] 上传返回无 media_id: ${JSON.stringify(resp.data)}`); - return null; - } catch (err: any) { - log?.error?.(`[DingTalk][${mediaType}] 上传失败: ${err.message}`); - return null; - } -} - -/** 扫描内容中的本地图片路径,上传到钉钉并替换为 media_id */ -async function processLocalImages( - content: string, - oapiToken: string | null, - log?: any, -): Promise { - if (!oapiToken) { - log?.warn?.(`[DingTalk][Media] 无 oapiToken,跳过图片后处理`); - return content; - } - - let result = content; - - // 第一步:匹配 markdown 图片语法 ![alt](path) - const mdMatches = [...content.matchAll(LOCAL_IMAGE_RE)]; - if (mdMatches.length > 0) { - log?.info?.(`[DingTalk][Media] 检测到 ${mdMatches.length} 个 markdown 图片,开始上传...`); - for (const match of mdMatches) { - const [fullMatch, alt, rawPath] = match; - // 清理转义字符(AI 可能会对含空格的路径添加 \ ) - const cleanPath = rawPath.replace(/\\ /g, ' '); - const mediaId = await uploadMediaToDingTalk(cleanPath, 'image', oapiToken, 20 * 1024 * 1024, log); - if (mediaId) { - result = result.replace(fullMatch, `![${alt}](${mediaId})`); - } - } - } - - // 第二步:匹配纯文本中的本地图片路径(如 `/var/folders/.../xxx.png`) - // 排除已被 markdown 图片语法包裹的路径 - const bareMatches = [...result.matchAll(BARE_IMAGE_PATH_RE)]; - const newBareMatches = bareMatches.filter(m => { - // 检查这个路径是否已经在 ![...](...) 中 - const idx = m.index!; - const before = result.slice(Math.max(0, idx - 10), idx); - return !before.includes(']('); - }); - - if (newBareMatches.length > 0) { - log?.info?.(`[DingTalk][Media] 检测到 ${newBareMatches.length} 个纯文本图片路径,开始上传...`); - // 从后往前替换,避免 index 偏移 - for (const match of newBareMatches.reverse()) { - const [fullMatch, rawPath] = match; - log?.info?.(`[DingTalk][Media] 纯文本图片: "${fullMatch}" -> path="${rawPath}"`); - const mediaId = await uploadMediaToDingTalk(rawPath, 'image', oapiToken, 20 * 1024 * 1024, log); - if (mediaId) { - const replacement = `![](${mediaId})`; - result = result.slice(0, match.index!) + result.slice(match.index!).replace(fullMatch, replacement); - log?.info?.(`[DingTalk][Media] 替换纯文本路径为图片: ${replacement}`); - } - } - } - - if (mdMatches.length === 0 && newBareMatches.length === 0) { - log?.info?.(`[DingTalk][Media] 未检测到本地图片路径`); - } - - return result; -} - -// ============ 文件后处理:提取文件标记并发送独立消息 ============ - -/** - * 文件标记正则:[DINGTALK_FILE]{"path":"...","fileName":"...","fileType":"..."}[/DINGTALK_FILE] - */ -const FILE_MARKER_PATTERN = /\[DINGTALK_FILE\]({.*?})\[\/DINGTALK_FILE\]/g; - -/** 视频大小限制:20MB */ -const MAX_VIDEO_SIZE = 20 * 1024 * 1024; - -// ============ 视频后处理:提取视频标记并发送视频消息 ============ - -/** - * 视频标记正则:[DINGTALK_VIDEO]{"path":"..."}[/DINGTALK_VIDEO] - */ -const VIDEO_MARKER_PATTERN = /\[DINGTALK_VIDEO\]({.*?})\[\/DINGTALK_VIDEO\]/g; - -/** - * 音频标记正则:[DINGTALK_AUDIO]{"path":"..."}[/DINGTALK_AUDIO] - */ -const AUDIO_MARKER_PATTERN = /\[DINGTALK_AUDIO\]({.*?})\[\/DINGTALK_AUDIO\]/g; - -/** 视频信息接口 */ -interface VideoInfo { - path: string; -} - -/** 视频元数据接口 */ -interface VideoMetadata { - duration: number; - width: number; - height: number; -} - -/** - * 提取视频元数据(时长、分辨率) - */ -async function extractVideoMetadata( - filePath: string, - log?: any, -): Promise { - try { - const ffmpeg = require('fluent-ffmpeg'); - const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path; - ffmpeg.setFfmpegPath(ffmpegPath); - - return new Promise((resolve, reject) => { - ffmpeg.ffprobe(filePath, (err: any, metadata: any) => { - if (err) { - log?.error?.(`[DingTalk][Video] 提取元数据失败: ${err.message}`); - return reject(err); - } - - const videoStream = metadata.streams.find((s: any) => s.codec_type === 'video'); - if (!videoStream) { - log?.warn?.(`[DingTalk][Video] 未找到视频流`); - return resolve(null); - } - - const result = { - duration: Math.floor(metadata.format.duration || 0), - width: videoStream.width || 0, - height: videoStream.height || 0, - }; - - log?.info?.(`[DingTalk][Video] 元数据: duration=${result.duration}s, ${result.width}x${result.height}`); - resolve(result); - }); - }); - } catch (err: any) { - log?.error?.(`[DingTalk][Video] ffprobe 失败: ${err.message}`); - return null; - } -} - -/** - * 生成视频封面图(第1秒截图) - */ -async function extractVideoThumbnail( - videoPath: string, - outputPath: string, - log?: any, -): Promise { - try { - const ffmpeg = require('fluent-ffmpeg'); - const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path; - const path = await import('path'); - ffmpeg.setFfmpegPath(ffmpegPath); - - return new Promise((resolve, reject) => { - ffmpeg(videoPath) - .screenshots({ - count: 1, - folder: path.dirname(outputPath), - filename: path.basename(outputPath), - timemarks: ['1'], - size: '?x360', - }) - .on('end', () => { - log?.info?.(`[DingTalk][Video] 封面生成成功: ${outputPath}`); - resolve(outputPath); - }) - .on('error', (err: any) => { - log?.error?.(`[DingTalk][Video] 封面生成失败: ${err.message}`); - reject(err); - }); - }); - } catch (err: any) { - log?.error?.(`[DingTalk][Video] ffmpeg 失败: ${err.message}`); - return null; - } -} - -/** - * 发送视频消息到钉钉 - */ -async function sendVideoMessage( - config: any, - sessionWebhook: string, - videoInfo: VideoInfo, - videoMediaId: string, - picMediaId: string, - metadata: VideoMetadata, - oapiToken: string, - log?: any, -): Promise { - try { - const path = await import('path'); - const fileName = path.basename(videoInfo.path); - - const payload = { - msgtype: 'video', - video: { - duration: metadata.duration.toString(), - videoMediaId: videoMediaId, - videoType: 'mp4', - picMediaId: picMediaId, - }, - }; - - log?.info?.(`[DingTalk][Video] 发送视频消息: ${fileName}, payload: ${JSON.stringify(payload)}`); - const resp = await axios.post(sessionWebhook, payload, { - headers: { - 'x-acs-dingtalk-access-token': oapiToken, - 'Content-Type': 'application/json', - }, - timeout: 10_000, - }); - - if (resp.data?.success !== false) { - log?.info?.(`[DingTalk][Video] 视频消息发送成功: ${fileName}`); - } else { - log?.error?.(`[DingTalk][Video] 视频消息发送失败: ${JSON.stringify(resp.data)}`); - } - } catch (err: any) { - log?.error?.(`[DingTalk][Video] 发送失败: ${err.message}`); - } -} - -/** - * 视频后处理主函数 - * 返回移除标记后的内容,并附带视频处理的状态提示 - * - * @param useProactiveApi 是否使用主动消息 API(用于 AI Card 场景) - * @param target 主动 API 需要的目标信息(useProactiveApi=true 时必须提供) - */ -async function processVideoMarkers( - content: string, - sessionWebhook: string, - config: any, - oapiToken: string | null, - log?: any, - useProactiveApi: boolean = false, - target?: AICardTarget, -): Promise { - const logPrefix = useProactiveApi ? '[DingTalk][Video][Proactive]' : '[DingTalk][Video]'; - - if (!oapiToken) { - log?.warn?.(`${logPrefix} 无 oapiToken,跳过视频处理`); - return content; - } - - const fs = await import('fs'); - const path = await import('path'); - const os = await import('os'); - - // 提取视频标记 - const matches = [...content.matchAll(VIDEO_MARKER_PATTERN)]; - const videoInfos: VideoInfo[] = []; - const invalidVideos: string[] = []; - - for (const match of matches) { - try { - const videoInfo = JSON.parse(match[1]) as VideoInfo; - if (videoInfo.path && fs.existsSync(videoInfo.path)) { - videoInfos.push(videoInfo); - log?.info?.(`${logPrefix} 提取到视频: ${videoInfo.path}`); - } else { - invalidVideos.push(videoInfo.path || '未知路径'); - log?.warn?.(`${logPrefix} 视频文件不存在: ${videoInfo.path}`); - } - } catch (err: any) { - log?.warn?.(`${logPrefix} 解析标记失败: ${err.message}`); - } - } - - if (videoInfos.length === 0 && invalidVideos.length === 0) { - log?.info?.(`${logPrefix} 未检测到视频标记`); - return content.replace(VIDEO_MARKER_PATTERN, '').trim(); - } - - // 先移除所有视频标记,保留其他文本内容 - let cleanedContent = content.replace(VIDEO_MARKER_PATTERN, '').trim(); - - // 收集处理结果状态 - const statusMessages: string[] = []; - - // 处理无效视频 - for (const invalidPath of invalidVideos) { - statusMessages.push(`⚠️ 视频文件不存在: ${path.basename(invalidPath)}`); - } - - if (videoInfos.length > 0) { - log?.info?.(`${logPrefix} 检测到 ${videoInfos.length} 个视频,开始处理...`); - } - - // 逐个处理视频 - for (const videoInfo of videoInfos) { - const fileName = path.basename(videoInfo.path); - let thumbnailPath = ''; - try { - // 1. 提取元数据 - const metadata = await extractVideoMetadata(videoInfo.path, log); - if (!metadata) { - log?.warn?.(`${logPrefix} 无法提取元数据: ${videoInfo.path}`); - statusMessages.push(`⚠️ 视频处理失败: ${fileName}(无法读取视频信息,请检查 ffmpeg 是否已安装)`); - continue; - } - - // 2. 生成封面 - thumbnailPath = path.join(os.tmpdir(), `thumbnail_${Date.now()}.jpg`); - const thumbnail = await extractVideoThumbnail(videoInfo.path, thumbnailPath, log); - if (!thumbnail) { - log?.warn?.(`${logPrefix} 无法生成封面: ${videoInfo.path}`); - statusMessages.push(`⚠️ 视频处理失败: ${fileName}(无法生成封面)`); - continue; - } - - // 3. 上传视频 - const videoMediaId = await uploadMediaToDingTalk(videoInfo.path, 'video', oapiToken, MAX_VIDEO_SIZE, log); - if (!videoMediaId) { - log?.warn?.(`${logPrefix} 视频上传失败: ${videoInfo.path}`); - statusMessages.push(`⚠️ 视频上传失败: ${fileName}(文件可能超过 20MB 限制)`); - continue; - } - - // 4. 上传封面 - const picMediaId = await uploadMediaToDingTalk(thumbnailPath, 'image', oapiToken, 20 * 1024 * 1024, log); - if (!picMediaId) { - log?.warn?.(`${logPrefix} 封面上传失败: ${thumbnailPath}`); - statusMessages.push(`⚠️ 视频封面上传失败: ${fileName}`); - continue; - } - - // 5. 发送视频消息 - if (useProactiveApi && target) { - await sendVideoProactive(config, target, videoMediaId, picMediaId, metadata, log); - } else { - await sendVideoMessage(config, sessionWebhook, videoInfo, videoMediaId, picMediaId, metadata, oapiToken, log); - } - - log?.info?.(`${logPrefix} 视频处理完成: ${fileName}`); - statusMessages.push(`✅ 视频已发送: ${fileName}`); - } catch (err: any) { - log?.error?.(`${logPrefix} 处理视频失败: ${err.message}`); - statusMessages.push(`⚠️ 视频处理异常: ${fileName}(${err.message})`); - } finally { - // 统一清理临时文件 - if (thumbnailPath) { - try { - fs.unlinkSync(thumbnailPath); - } catch { - // 文件可能不存在,忽略删除错误 - } - } - } - } - - // 将状态信息附加到清理后的内容 - if (statusMessages.length > 0) { - const statusText = statusMessages.join('\n'); - cleanedContent = cleanedContent - ? `${cleanedContent}\n\n${statusText}` - : statusText; - } - - return cleanedContent; -} - -/** 音频文件扩展名 */ -const AUDIO_EXTENSIONS = ['mp3', 'wav', 'amr', 'ogg', 'aac', 'flac', 'm4a']; - - -/** 判断是否为音频文件 */ -function isAudioFile(fileType: string): boolean { - return AUDIO_EXTENSIONS.includes(fileType.toLowerCase()); -} - -/** 文件大小限制:20MB(字节) */ -const MAX_FILE_SIZE = 20 * 1024 * 1024; - -/** 文件信息接口 */ -interface FileInfo { - path: string; // 本地文件路径 - fileName: string; // 文件名 - fileType: string; // 文件类型(扩展名) -} - -/** - * 从内容中提取文件标记 - * @returns { cleanedContent, fileInfos } - */ -function extractFileMarkers(content: string, log?: any): { cleanedContent: string; fileInfos: FileInfo[] } { - const fileInfos: FileInfo[] = []; - const matches = [...content.matchAll(FILE_MARKER_PATTERN)]; - - for (const match of matches) { - try { - const fileInfo = JSON.parse(match[1]) as FileInfo; - - // 验证必需字段 - if (fileInfo.path && fileInfo.fileName) { - fileInfos.push(fileInfo); - log?.info?.(`[DingTalk][File] 提取到文件标记: ${fileInfo.fileName}`); - } - } catch (err: any) { - log?.warn?.(`[DingTalk][File] 解析文件标记失败: ${match[1]}, 错误: ${err.message}`); - } - } - - // 移除文件标记,返回清理后的内容 - const cleanedContent = content.replace(FILE_MARKER_PATTERN, '').trim(); - return { cleanedContent, fileInfos }; -} - - -/** - * 发送文件消息到钉钉 - */ -async function sendFileMessage( - config: any, - sessionWebhook: string, - fileInfo: FileInfo, - mediaId: string, - oapiToken: string, - log?: any, -): Promise { - try { - const fileMessage = { - msgtype: 'file', - file: { - mediaId: mediaId, - fileName: fileInfo.fileName, - fileType: fileInfo.fileType, - }, - }; - - log?.info?.(`[DingTalk][File] 发送文件消息: ${fileInfo.fileName}`); - const resp = await axios.post(sessionWebhook, fileMessage, { - headers: { - 'x-acs-dingtalk-access-token': oapiToken, - 'Content-Type': 'application/json', - }, - timeout: 10_000, - }); - - if (resp.data?.success !== false) { - log?.info?.(`[DingTalk][File] 文件消息发送成功: ${fileInfo.fileName}`); - } else { - log?.error?.(`[DingTalk][File] 文件消息发送失败: ${JSON.stringify(resp.data)}`); - } - } catch (err: any) { - log?.error?.(`[DingTalk][File] 发送文件消息异常: ${fileInfo.fileName}, 错误: ${err.message}`); - } -} - -/** - * 获取 ffprobe 可执行文件路径 - * 优先级: @ffprobe-installer/ffprobe > FFPROBE_PATH 环境变量 > 系统 PATH - */ -function getFfprobePath(): string { - // 1. 尝试 @ffprobe-installer/ffprobe 包 - try { - const ffprobePath = require('@ffprobe-installer/ffprobe').path; - if (ffprobePath) return ffprobePath; - } catch { /* 未安装,跳过 */ } - - // 2. 尝试环境变量 - if (process.env.FFPROBE_PATH) return process.env.FFPROBE_PATH; - - // 3. fallback 到系统 PATH - return 'ffprobe'; -} - -/** - * 提取音频文件时长(毫秒) - * 使用 ffprobe CLI 直接获取,避免 fluent-ffmpeg 在部分运行环境中回调不触发的问题 - */ -async function extractAudioDuration( - filePath: string, - log?: any, -): Promise { - try { - const { execFile } = require('child_process'); - const ffprobeBin = getFfprobePath(); - - return new Promise((resolve) => { - execFile(ffprobeBin, [ - '-v', 'quiet', - '-print_format', 'json', - '-show_format', - filePath, - ], { timeout: 10_000 }, (err: any, stdout: string, stderr: string) => { - if (err) { - log?.error?.(`[DingTalk][Audio] ffprobe 执行失败 (${ffprobeBin}): ${err.message}`); - return resolve(null); - } - - try { - const parsed = JSON.parse(stdout); - const durationSec = parseFloat(parsed?.format?.duration); - if (isNaN(durationSec)) { - log?.warn?.(`[DingTalk][Audio] 无法解析音频时长,ffprobe 输出: ${stdout.slice(0, 200)}`); - return resolve(null); - } - - const durationMs = Math.floor(durationSec * 1000); - log?.info?.(`[DingTalk][Audio] 音频时长: ${durationMs}ms (${durationSec}s)`); - resolve(durationMs); - } catch (parseErr: any) { - log?.error?.(`[DingTalk][Audio] ffprobe 输出解析失败: ${parseErr.message}`); - resolve(null); - } - }); - }); - } catch (err: any) { - log?.error?.(`[DingTalk][Audio] extractAudioDuration 异常: ${err.message}`); - return null; - } -} - -/** - * 发送音频消息到钉钉(被动回复场景) - */ -async function sendAudioMessage( - config: any, - sessionWebhook: string, - fileInfo: FileInfo, - mediaId: string, - oapiToken: string, - log?: any, - durationMs?: number, -): Promise { - try { - // 钉钉语音消息格式 - const actualDuration = (durationMs && durationMs > 0) ? durationMs.toString() : '60000'; - const audioMessage = { - msgtype: 'voice', - voice: { - mediaId: mediaId, - duration: actualDuration, - }, - }; - - log?.info?.(`[DingTalk][Audio] 发送语音消息: ${fileInfo.fileName}`); - const resp = await axios.post(sessionWebhook, audioMessage, { - headers: { - 'x-acs-dingtalk-access-token': oapiToken, - 'Content-Type': 'application/json', - }, - timeout: 10_000, - }); - - if (resp.data?.success !== false) { - log?.info?.(`[DingTalk][Audio] 语音消息发送成功: ${fileInfo.fileName}`); - } else { - log?.error?.(`[DingTalk][Audio] 语音消息发送失败: ${JSON.stringify(resp.data)}`); - } - } catch (err: any) { - log?.error?.(`[DingTalk][Audio] 发送语音消息异常: ${fileInfo.fileName}, 错误: ${err.message}`); - } -} - -/** - * 处理文件标记:提取、上传、发送独立消息 - * 返回移除标记后的内容,并附带文件处理的状态提示 - * - * @param useProactiveApi 是否使用主动消息 API(用于 AI Card 场景,避免 sessionWebhook 失效问题) - * @param target 主动 API 需要的目标信息(useProactiveApi=true 时必须提供) - */ -async function processFileMarkers( - content: string, - sessionWebhook: string, - config: any, - oapiToken: string | null, - log?: any, - useProactiveApi: boolean = false, - target?: AICardTarget, -): Promise { - if (!oapiToken) { - log?.warn?.(`[DingTalk][File] 无 oapiToken,跳过文件处理`); - return content; - } - - const { cleanedContent, fileInfos } = extractFileMarkers(content, log); - - if (fileInfos.length === 0) { - log?.info?.(`[DingTalk][File] 未检测到文件标记`); - return cleanedContent; - } - - log?.info?.(`[DingTalk][File] 检测到 ${fileInfos.length} 个文件标记,开始处理... (useProactiveApi=${useProactiveApi})`); - - const statusMessages: string[] = []; - - const fs = await import('fs'); - - // 逐个上传并发送文件消息 - for (const fileInfo of fileInfos) { - // 预检查:文件是否存在、是否超限 - const absPath = toLocalPath(fileInfo.path); - if (!fs.existsSync(absPath)) { - statusMessages.push(`⚠️ 文件不存在: ${fileInfo.fileName}`); - continue; - } - const stats = fs.statSync(absPath); - if (stats.size > MAX_FILE_SIZE) { - const sizeMB = (stats.size / (1024 * 1024)).toFixed(1); - const maxMB = (MAX_FILE_SIZE / (1024 * 1024)).toFixed(0); - statusMessages.push(`⚠️ 文件过大无法发送: ${fileInfo.fileName}(${sizeMB}MB,限制 ${maxMB}MB)`); - continue; - } - - // 区分音频文件和普通文件 - if (isAudioFile(fileInfo.fileType)) { - // 音频文件使用 voice 类型上传 - const mediaId = await uploadMediaToDingTalk(fileInfo.path, 'voice', oapiToken, MAX_FILE_SIZE, log); - if (mediaId) { - // 提取音频实际时长 - const audioDurationMs = await extractAudioDuration(fileInfo.path, log); - if (useProactiveApi && target) { - // 使用主动消息 API(适用于 AI Card 场景) - await sendAudioProactive(config, target, fileInfo, mediaId, log, audioDurationMs ?? undefined); - } else { - // 使用 sessionWebhook(传统被动回复场景) - await sendAudioMessage(config, sessionWebhook, fileInfo, mediaId, oapiToken, log, audioDurationMs ?? undefined); - } - statusMessages.push(`✅ 音频已发送: ${fileInfo.fileName}`); - } else { - log?.error?.(`[DingTalk][Audio] 音频上传失败,跳过发送: ${fileInfo.fileName}`); - statusMessages.push(`⚠️ 音频上传失败: ${fileInfo.fileName}`); - } - } else { - // 普通文件 - const mediaId = await uploadMediaToDingTalk(fileInfo.path, 'file', oapiToken, MAX_FILE_SIZE, log); - if (mediaId) { - if (useProactiveApi && target) { - // 使用主动消息 API(适用于 AI Card 场景) - await sendFileProactive(config, target, fileInfo, mediaId, log); - } else { - // 使用 sessionWebhook(传统被动回复场景) - await sendFileMessage(config, sessionWebhook, fileInfo, mediaId, oapiToken, log); - } - statusMessages.push(`✅ 文件已发送: ${fileInfo.fileName}`); - } else { - log?.error?.(`[DingTalk][File] 文件上传失败,跳过发送: ${fileInfo.fileName}`); - statusMessages.push(`⚠️ 文件上传失败: ${fileInfo.fileName}`); - } - } - } - - // 将状态信息附加到清理后的内容 - if (statusMessages.length > 0) { - const statusText = statusMessages.join('\n'); - return cleanedContent - ? `${cleanedContent}\n\n${statusText}` - : statusText; - } - - return cleanedContent; -} - -// ============ AI Card Streaming ============ - -const DINGTALK_API = 'https://api.dingtalk.com'; -const DINGTALK_OAPI = 'https://oapi.dingtalk.com'; -const AI_CARD_TEMPLATE_ID = '382e4302-551d-4880-bf29-a30acfab2e71.schema'; - -// flowStatus 值与 Python SDK AICardStatus 一致(cardParamMap 的值必须是字符串) -const AICardStatus = { - PROCESSING: '1', - INPUTING: '2', - FINISHED: '3', - EXECUTING: '4', - FAILED: '5', -} as const; - -interface AICardInstance { - cardInstanceId: string; - accessToken: string; - inputingStarted: boolean; -} - -/** - * 创建 AI Card 实例(被动回复场景) - * 从钉钉回调 data 中提取目标信息,委托给通用函数 - */ -async function createAICard( - config: any, - data: any, - log?: any, -): Promise { - const isGroup = data.conversationType === '2'; - - log?.info?.(`[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${data.conversationId}, senderStaffId=${data.senderStaffId}, senderId=${data.senderId}`); - - // 构建通用目标 - const target: AICardTarget = isGroup - ? { type: 'group', openConversationId: data.conversationId } - : { type: 'user', userId: data.senderStaffId || data.senderId }; - - return createAICardForTarget(config, target, log); -} - -// 流式更新 AI Card 内容 -async function streamAICard( - card: AICardInstance, - content: string, - finished: boolean = false, - log?: any, -): Promise { - // 首次 streaming 前,先切换到 INPUTING 状态(与 Python SDK get_card_data(INPUTING) 一致) - if (!card.inputingStarted) { - const statusBody = { - outTrackId: card.cardInstanceId, - cardData: { - cardParamMap: { - flowStatus: AICardStatus.INPUTING, - msgContent: '', - staticMsgContent: '', - sys_full_json_obj: JSON.stringify({ - order: ['msgContent'], // 只声明实际使用的字段,避免部分客户端显示空占位 - }), - }, - }, - }; - log?.info?.(`[DingTalk][AICard] PUT /v1.0/card/instances (INPUTING) outTrackId=${card.cardInstanceId}`); - try { - const statusResp = await axios.put(`${DINGTALK_API}/v1.0/card/instances`, statusBody, { - headers: { 'x-acs-dingtalk-access-token': card.accessToken, 'Content-Type': 'application/json' }, - }); - log?.info?.(`[DingTalk][AICard] INPUTING 响应: status=${statusResp.status} data=${JSON.stringify(statusResp.data)}`); - } catch (err: any) { - log?.error?.(`[DingTalk][AICard] INPUTING 切换失败: ${err.message}, resp=${JSON.stringify(err.response?.data)}`); - throw err; - } - card.inputingStarted = true; - } - - // 调用 streaming API 更新内容 - const body = { - outTrackId: card.cardInstanceId, - guid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - key: 'msgContent', - content: content, - isFull: true, // 全量替换 - isFinalize: finished, - isError: false, - }; - - log?.info?.(`[DingTalk][AICard] PUT /v1.0/card/streaming contentLen=${content.length} isFinalize=${finished} guid=${body.guid}`); - try { - const streamResp = await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, body, { - headers: { 'x-acs-dingtalk-access-token': card.accessToken, 'Content-Type': 'application/json' }, - }); - log?.info?.(`[DingTalk][AICard] streaming 响应: status=${streamResp.status}`); - } catch (err: any) { - log?.error?.(`[DingTalk][AICard] streaming 更新失败: ${err.message}, resp=${JSON.stringify(err.response?.data)}`); - throw err; - } -} - -// 完成 AI Card:先 streaming isFinalize 关闭流式通道,再 put_card_data 更新 FINISHED 状态 -async function finishAICard( - card: AICardInstance, - content: string, - log?: any, -): Promise { - log?.info?.(`[DingTalk][AICard] 开始 finish,最终内容长度=${content.length}`); - - // 1. 先用最终内容关闭流式通道(isFinalize=true),确保卡片显示替换后的内容 - await streamAICard(card, content, true, log); - - // 2. 更新卡片状态为 FINISHED - const body = { - outTrackId: card.cardInstanceId, - cardData: { - cardParamMap: { - flowStatus: AICardStatus.FINISHED, - msgContent: content, - staticMsgContent: '', - sys_full_json_obj: JSON.stringify({ - order: ['msgContent'], // 只声明实际使用的字段,避免部分客户端显示空占位 - }), - }, - }, - }; - - log?.info?.(`[DingTalk][AICard] PUT /v1.0/card/instances (FINISHED) outTrackId=${card.cardInstanceId}`); - try { - const finishResp = await axios.put(`${DINGTALK_API}/v1.0/card/instances`, body, { - headers: { 'x-acs-dingtalk-access-token': card.accessToken, 'Content-Type': 'application/json' }, - }); - log?.info?.(`[DingTalk][AICard] FINISHED 响应: status=${finishResp.status} data=${JSON.stringify(finishResp.data)}`); - } catch (err: any) { - log?.error?.(`[DingTalk][AICard] FINISHED 更新失败: ${err.message}, resp=${JSON.stringify(err.response?.data)}`); - } -} - -// ============ Gateway SSE Streaming ============ - -// ============ Bindings 匹配逻辑 ============ - -interface BindingMatch { - channel?: string; - accountId?: string; - peer?: { - kind?: 'direct' | 'group'; - id?: string; - }; -} - -interface Binding { - agentId: string; - match?: BindingMatch; -} - -/** - * 根据 OpenClaw bindings 配置解析 agentId - * - * 匹配优先级(从高到低): - * 1. peer.kind + peer.id 精确匹配(非 '*') - * 2. peer.kind + peer.id='*' 通配匹配 - * 3. peer.kind 匹配(无 peer.id) - * 4. accountId 匹配 - * 5. channel 匹配 - * 6. 默认 fallback - * - * @param accountId 账号 ID - * @param peerKind 会话类型:'direct'(单聊)或 'group'(群聊) - * @param peerId 发送者 ID(单聊)或会话 ID(群聊) - * @param log 日志对象 - * @returns 匹配到的 agentId - */ -function resolveAgentIdByBindings( - accountId: string, - peerKind: 'direct' | 'group', - peerId: string, - log?: any, -): string { - const rt = getRuntime(); - const defaultAgentId = accountId === DEFAULT_ACCOUNT_ID ? 'main' : accountId; - - // 读取 OpenClaw 配置 - let bindings: Binding[] = []; - try { - const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json'); - if (fs.existsSync(configPath)) { - const configContent = fs.readFileSync(configPath, 'utf-8'); - const config = JSON.parse(configContent); - bindings = config.bindings || []; - } - } catch (err: any) { - log?.warn?.(`[DingTalk][Bindings] 读取 OpenClaw 配置失败: ${err.message}`); - return defaultAgentId; - } - - if (bindings.length === 0) { - log?.info?.(`[DingTalk][Bindings] 无 bindings 配置,使用默认 agentId=${defaultAgentId}`); - return defaultAgentId; - } - - // 筛选 channel='dingtalk-connector' 的 bindings - const channelBindings = bindings.filter(b => - !b.match?.channel || b.match.channel === 'dingtalk-connector' - ); - - if (channelBindings.length === 0) { - log?.info?.(`[DingTalk][Bindings] 无匹配 channel 的 bindings,使用默认 agentId=${defaultAgentId}`); - return defaultAgentId; - } - - log?.info?.(`[DingTalk][Bindings] 开始匹配: accountId=${accountId}, peerKind=${peerKind}, peerId=${peerId}, bindings数量=${channelBindings.length}`); - - // 按优先级匹配 - // 优先级1: peer.kind + peer.id 精确匹配 - for (const binding of channelBindings) { - const match = binding.match || {}; - if (match.peer?.kind === peerKind && - match.peer?.id && - match.peer.id !== '*' && - match.peer.id === peerId) { - // 还需检查 accountId 是否匹配(如果指定了) - if (match.accountId && match.accountId !== accountId) continue; - log?.info?.(`[DingTalk][Bindings] 精确匹配 peer.id: agentId=${binding.agentId}`); - return binding.agentId || defaultAgentId; - } - } - - // 优先级2: peer.kind + peer.id='*' 通配匹配 - for (const binding of channelBindings) { - const match = binding.match || {}; - if (match.peer?.kind === peerKind && match.peer?.id === '*') { - if (match.accountId && match.accountId !== accountId) continue; - log?.info?.(`[DingTalk][Bindings] 通配匹配 peer.kind=${peerKind}, peer.id=*: agentId=${binding.agentId}`); - return binding.agentId || defaultAgentId; - } - } - - // 优先级3: 仅 peer.kind 匹配(无 peer.id) - for (const binding of channelBindings) { - const match = binding.match || {}; - if (match.peer?.kind === peerKind && !match.peer?.id) { - if (match.accountId && match.accountId !== accountId) continue; - log?.info?.(`[DingTalk][Bindings] 匹配 peer.kind=${peerKind}: agentId=${binding.agentId}`); - return binding.agentId || defaultAgentId; - } - } - - // 优先级4: accountId 匹配(无 peer 配置) - for (const binding of channelBindings) { - const match = binding.match || {}; - if (!match.peer && match.accountId === accountId) { - log?.info?.(`[DingTalk][Bindings] 匹配 accountId=${accountId}: agentId=${binding.agentId}`); - return binding.agentId || defaultAgentId; - } - } - - // 优先级5: 仅 channel 匹配(无 peer 和 accountId) - for (const binding of channelBindings) { - const match = binding.match || {}; - if (!match.peer && !match.accountId) { - log?.info?.(`[DingTalk][Bindings] 匹配 channel=dingtalk-connector: agentId=${binding.agentId}`); - return binding.agentId || defaultAgentId; - } - } - - log?.info?.(`[DingTalk][Bindings] 无匹配,使用默认 agentId=${defaultAgentId}`); - return defaultAgentId; -} - -interface GatewayOptions { - userContent: string; - systemPrompts: string[]; - sessionContext: SessionContext; - gatewayAuth?: string; // token 或 password,都用 Bearer 格式 - /** 记忆归属用户标识,用于 Gateway 区分记忆;sharedMemoryAcrossConversations=true 时传 accountId,false 时传 sessionContext JSON */ - memoryUser?: string; - /** 本地图片文件路径列表,用于 OpenClaw AgentMediaPayload */ - imageLocalPaths?: string[]; - /** 会话类型:'direct'(单聊)或 'group'(群聊),用于 bindings 匹配 */ - peerKind?: 'direct' | 'group'; - /** 发送者 ID,用于 bindings 匹配 */ - peerId?: string; - gatewayPort?: number; - log?: any; -} - -async function* streamFromGateway(options: GatewayOptions, accountId: string): AsyncGenerator { - const { userContent, systemPrompts, sessionKey, gatewayAuth, memoryUser, imageLocalPaths, peerKind, peerId, gatewayPort, log } = options; - const rt = getRuntime(); - const port = gatewayPort || rt.gateway?.port || 18789; - const gatewayUrl = `http://127.0.0.1:${port}/v1/chat/completions`; - - const messages: any[] = []; - for (const prompt of systemPrompts) { - messages.push({ role: 'system', content: prompt }); - } - - // 如果有图片,在文本中嵌入本地文件路径(OpenClaw AgentMediaPayload 格式) - let finalContent = userContent; - if (imageLocalPaths && imageLocalPaths.length > 0) { - const imageMarkdown = imageLocalPaths.map(p => `![image](file://${p})`).join('\n'); - finalContent = finalContent ? `${finalContent}\n\n${imageMarkdown}` : imageMarkdown; - log?.info?.(`[DingTalk][Gateway] 附加 ${imageLocalPaths.length} 张本地图片路径`); - } - messages.push({ role: 'user', content: finalContent }); - - const headers: Record = { 'Content-Type': 'application/json' }; - if (gatewayAuth) { - headers['Authorization'] = `Bearer ${gatewayAuth}`; - } - // 使用 bindings 配置解析 agentId,支持基于 peer.kind(单聊/群聊)的路由 - // 如果没有提供 peerKind/peerId,则回退到原有逻辑 - const agentId = (peerKind && peerId) - ? resolveAgentIdByBindings(accountId, peerKind, peerId, log) - : (accountId === DEFAULT_ACCOUNT_ID ? 'main' : accountId); - headers['X-OpenClaw-Agent-Id'] = agentId; - if (memoryUser) { - // 使用 Base64 编码处理可能包含中文字符的 memoryUser - // HTTP Header 只能包含 ASCII 字符,中文字符会导致 ByteString 编码错误 - headers['X-OpenClaw-Memory-User'] = Buffer.from(memoryUser, 'utf-8').toString('base64'); - } - - log?.info?.(`[DingTalk][Gateway] POST ${gatewayUrl}, session=${sessionKey}, accountId=${accountId}, agentId=${agentId}, peerKind=${peerKind}, messages=${messages.length}`); - - const response = await fetch(gatewayUrl, { - method: 'POST', - headers, - body: JSON.stringify({ - model: 'main', - messages, - stream: true, - user: sessionKey, // 用于 session 持久化 - }), - }); - - log?.info?.(`[DingTalk][Gateway] 响应 status=${response.status}, ok=${response.ok}, hasBody=${!!response.body}`); - - if (!response.ok || !response.body) { - const errText = response.body ? await response.text() : '(no body)'; - log?.error?.(`[DingTalk][Gateway] 错误响应: ${errText}`); - throw new Error(`Gateway error: ${response.status} - ${errText}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const data = line.slice(6).trim(); - if (data === '[DONE]') return; - - try { - const chunk = JSON.parse(data); - const content = chunk.choices?.[0]?.delta?.content; - if (content) yield content; - } catch {} - } - } -} - -// ============ 图片下载到本地文件 ============ - -/** - * 下载钉钉图片到本地临时文件 - * 返回本地文件路径,用于 OpenClaw AgentMediaPayload - */ -async function downloadImageToFile( - downloadUrl: string, - log?: any, -): Promise { - try { - log?.info?.(`[DingTalk][Image] 开始下载图片: ${downloadUrl.slice(0, 100)}...`); - const resp = await axios.get(downloadUrl, { - responseType: 'arraybuffer', - timeout: 30_000, - }); - - const buffer = Buffer.from(resp.data); - const contentType = resp.headers['content-type'] || 'image/jpeg'; - const ext = contentType.includes('png') ? '.png' : contentType.includes('gif') ? '.gif' : contentType.includes('webp') ? '.webp' : '.jpg'; - const mediaDir = path.join(os.homedir(), '.openclaw', 'workspace', 'media', 'inbound'); - fs.mkdirSync(mediaDir, { recursive: true }); - const tmpFile = path.join(mediaDir, `openclaw-media-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`); - fs.writeFileSync(tmpFile, buffer); - - log?.info?.(`[DingTalk][Image] 图片下载成功: size=${buffer.length} bytes, type=${contentType}, path=${tmpFile}`); - return tmpFile; - } catch (err: any) { - log?.error?.(`[DingTalk][Image] 图片下载失败: ${err.message}`); - return null; - } -} - -/** - * 通过钉钉 API 下载媒体文件(需要 access_token) - * 适用于 picture/file 类型的 downloadCode - */ -async function downloadMediaByCode( - downloadCode: string, - config: any, - log?: any, -): Promise { - try { - const token = await getAccessToken(config); - log?.info?.(`[DingTalk][Image] 通过 downloadCode 下载媒体: ${downloadCode.slice(0, 30)}...`); - - const resp = await axios.post( - `${DINGTALK_API}/v1.0/robot/messageFiles/download`, - { downloadCode, robotCode: config.clientId }, - { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - timeout: 30_000, - }, - ); - - const downloadUrl = resp.data?.downloadUrl; - if (!downloadUrl) { - log?.warn?.(`[DingTalk][Image] downloadCode 换取 downloadUrl 失败: ${JSON.stringify(resp.data)}`); - return null; - } - - return downloadImageToFile(downloadUrl, log); - } catch (err: any) { - log?.error?.(`[DingTalk][Image] downloadCode 下载失败: ${err.message}`); - return null; - } -} - -/** - * 通过钉钉 API 下载文件附件(需要 access_token) - * 与 downloadMediaByCode 不同,此函数保留原始文件名 - */ -async function downloadFileByCode( - downloadCode: string, - fileName: string, - config: any, - log?: any, -): Promise { - try { - const token = await getAccessToken(config); - log?.info?.(`[DingTalk][File] 通过 downloadCode 下载文件: ${fileName}`); - - const resp = await axios.post( - `${DINGTALK_API}/v1.0/robot/messageFiles/download`, - { downloadCode, robotCode: config.clientId }, - { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - timeout: 30_000, - }, - ); - - const downloadUrl = resp.data?.downloadUrl; - if (!downloadUrl) { - log?.warn?.(`[DingTalk][File] downloadCode 换取 downloadUrl 失败: ${JSON.stringify(resp.data)}`); - return null; - } - - // 下载文件内容 - const fileResp = await axios.get(downloadUrl, { - responseType: 'arraybuffer', - timeout: 60_000, - }); - - const buffer = Buffer.from(fileResp.data); - const mediaDir = path.join(os.homedir(), '.openclaw', 'workspace', 'media', 'inbound'); - fs.mkdirSync(mediaDir, { recursive: true }); - - // 用时间戳前缀避免文件名冲突,保留原始文件名 - const safeFileName = fileName.replace(/[/\\:*?"<>|]/g, '_'); - const localPath = path.join(mediaDir, `${Date.now()}-${safeFileName}`); - fs.writeFileSync(localPath, buffer); - - log?.info?.(`[DingTalk][File] 文件下载成功: size=${buffer.length} bytes, path=${localPath}`); - return localPath; - } catch (err: any) { - log?.error?.(`[DingTalk][File] 文件下载失败: ${err.message}`); - return null; - } -} - -/** 可直接读取内容的文本类文件扩展名 */ -const TEXT_FILE_EXTENSIONS = new Set(['.txt', '.md', '.csv', '.json', '.xml', '.yaml', '.yml', '.html', '.htm', '.log', '.conf', '.ini', '.sh', '.py', '.js', '.ts', '.css', '.sql']); - -/** 需要保存但无法直接读取的 Office/二进制文件扩展名 */ -const OFFICE_FILE_EXTENSIONS = new Set(['.docx', '.xlsx', '.pptx', '.pdf', '.doc', '.xls', '.ppt', '.zip', '.rar', '.7z']); - -// ============ 消息处理 ============ - -/** 消息内容提取结果 */ -interface ExtractedMessage { - text: string; - messageType: string; - /** 图片 URL 列表(来自 richText 或 picture 消息) */ - imageUrls: string[]; - /** 图片 downloadCode 列表(用于通过 API 下载) */ - downloadCodes: string[]; - /** 文件名列表(与 downloadCodes 对应,用于文件类型消息) */ - fileNames: string[]; - /** at的钉钉用户ID列表 */ - atDingtalkIds: string[]; - /** at的手机号列表 */ - atMobiles: string[]; -} - -function extractMessageContent(data: any): ExtractedMessage { - const msgtype = data.msgtype || 'text'; - switch (msgtype) { - case 'text': { - const atDingtalkIds = data.text?.at?.atDingtalkIds || []; - const atMobiles = data.text?.at?.atMobiles || []; - return { - text: data.text?.content?.trim() || '', - messageType: 'text', - imageUrls: [], - downloadCodes: [], - fileNames: [], - atDingtalkIds, - atMobiles - }; - } - case 'richText': { - const parts = data.content?.richText || []; - const textParts: string[] = []; - const imageUrls: string[] = []; - - for (const part of parts) { - if (part.text) { - textParts.push(part.text); - } - if (part.pictureUrl) { - imageUrls.push(part.pictureUrl); - } - if (part.type === 'picture' && part.downloadCode) { - // 有些 richText 图片通过 downloadCode 获取 - imageUrls.push(`downloadCode:${part.downloadCode}`); - } - } - - const text = textParts.join('') || (imageUrls.length > 0 ? '[图片]' : '[富文本消息]'); - return { text, messageType: 'richText', imageUrls, downloadCodes: [], fileNames: [], atDingtalkIds: [], atMobiles: [] }; - } - case 'picture': { - const downloadCode = data.content?.downloadCode || ''; - const pictureUrl = data.content?.pictureUrl || ''; - const imageUrls: string[] = []; - const downloadCodes: string[] = []; - - if (pictureUrl) { - imageUrls.push(pictureUrl); - } - if (downloadCode) { - downloadCodes.push(downloadCode); - } - - return { text: '[图片]', messageType: 'picture', imageUrls, downloadCodes, fileNames: [], atDingtalkIds: [], atMobiles: [] }; - } - case 'audio': - return { text: data.content?.recognition || '[语音消息]', messageType: 'audio', imageUrls: [], downloadCodes: [], fileNames: [], atDingtalkIds: [], atMobiles: [] }; - case 'video': - return { text: '[视频]', messageType: 'video', imageUrls: [], downloadCodes: [], fileNames: [], atDingtalkIds: [], atMobiles: [] }; - case 'file': { - const fileName = data.content?.fileName || '文件'; - const downloadCode = data.content?.downloadCode || ''; - const downloadCodes: string[] = []; - const fileNames: string[] = []; - if (downloadCode) { - downloadCodes.push(downloadCode); - fileNames.push(fileName); - } - return { text: `[文件: ${fileName}]`, messageType: 'file', imageUrls: [], downloadCodes, fileNames, atDingtalkIds: [], atMobiles: [] }; - } - default: - return { text: data.text?.content?.trim() || `[${msgtype}消息]`, messageType: msgtype, imageUrls: [], downloadCodes: [], fileNames: [], atDingtalkIds: [], atMobiles: [] }; - } -} - -// 发送 Markdown 消息 -async function sendMarkdownMessage( - config: any, - sessionWebhook: string, - title: string, - markdown: string, - options: any = {}, -): Promise { - const token = await getAccessToken(config); - let text = markdown; - if (options.atUserId) text = `${text} @${options.atUserId}`; - - const body: any = { - msgtype: 'markdown', - markdown: { title: title || 'Moltbot', text }, - }; - if (options.atUserId) body.at = { atUserIds: [options.atUserId], isAtAll: false }; - - return (await axios.post(sessionWebhook, body, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - })).data; -} - -// 发送文本消息 -async function sendTextMessage( - config: any, - sessionWebhook: string, - text: string, - options: any = {}, -): Promise { - const token = await getAccessToken(config); - const body: any = { msgtype: 'text', text: { content: text } }; - if (options.atUserId) body.at = { atUserIds: [options.atUserId], isAtAll: false }; - - return (await axios.post(sessionWebhook, body, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - })).data; -} - -// 智能选择 text / markdown -async function sendMessage( - config: any, - sessionWebhook: string, - text: string, - options: any = {}, -): Promise { - const hasMarkdown = /^[#*>-]|[*_`#\[\]]/.test(text) || text.includes('\n'); - const useMarkdown = options.useMarkdown !== false && (options.useMarkdown || hasMarkdown); - - if (useMarkdown) { - const title = options.title - || text.split('\n')[0].replace(/^[#*\s\->]+/, '').slice(0, 20) - || 'Moltbot'; - return sendMarkdownMessage(config, sessionWebhook, title, text, options); - } - return sendTextMessage(config, sessionWebhook, text, options); -} - -// ============ 主动发送消息 API ============ - -/** 消息类型枚举 */ -type DingTalkMsgType = 'text' | 'markdown' | 'link' | 'actionCard' | 'image'; - -/** 主动发送消息的结果 */ -interface SendResult { - ok: boolean; - processQueryKey?: string; - cardInstanceId?: string; // AI Card 成功时返回 - error?: string; - usedAICard?: boolean; // 是否使用了 AI Card -} - -/** 主动发送选项 */ -interface ProactiveSendOptions { - msgType?: DingTalkMsgType; - title?: string; - log?: any; - useAICard?: boolean; // 是否使用 AI Card,默认 true - fallbackToNormal?: boolean; // AI Card 失败时是否降级到普通消息,默认 true -} - -/** AI Card 投放目标类型 */ -type AICardTarget = - | { type: 'user'; userId: string } - | { type: 'group'; openConversationId: string }; - -/** - * 构建卡片投放请求体(提取公共逻辑) - */ -function buildDeliverBody( - cardInstanceId: string, - target: AICardTarget, - robotCode: string, -): any { - const base = { outTrackId: cardInstanceId, userIdType: 1 }; - - if (target.type === 'group') { - return { - ...base, - openSpaceId: `dtv1.card//IM_GROUP.${target.openConversationId}`, - imGroupOpenDeliverModel: { robotCode }, - }; - } - - return { - ...base, - openSpaceId: `dtv1.card//IM_ROBOT.${target.userId}`, - imRobotOpenDeliverModel: { spaceType: 'IM_ROBOT', robotCode }, - }; -} - -/** - * 通用 AI Card 创建函数 - * 支持被动回复和主动发送两种场景 - */ -async function createAICardForTarget( - config: any, - target: AICardTarget, - log?: any, -): Promise { - const targetDesc = target.type === 'group' - ? `群聊 ${target.openConversationId}` - : `用户 ${target.userId}`; - - try { - const token = await getAccessToken(config); - const cardInstanceId = `card_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; - - log?.info?.(`[DingTalk][AICard] 开始创建卡片: ${targetDesc}, outTrackId=${cardInstanceId}`); - - // 1. 创建卡片实例 - const createBody = { - cardTemplateId: AI_CARD_TEMPLATE_ID, - outTrackId: cardInstanceId, - cardData: { cardParamMap: {} }, - callbackType: 'STREAM', - imGroupOpenSpaceModel: { supportForward: true }, - imRobotOpenSpaceModel: { supportForward: true }, - }; - - log?.info?.(`[DingTalk][AICard] POST /v1.0/card/instances`); - const createResp = await axios.post(`${DINGTALK_API}/v1.0/card/instances`, createBody, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - }); - log?.info?.(`[DingTalk][AICard] 创建卡片响应: status=${createResp.status}`); - - // 2. 投放卡片 - const deliverBody = buildDeliverBody(cardInstanceId, target, config.clientId); - - log?.info?.(`[DingTalk][AICard] POST /v1.0/card/instances/deliver body=${JSON.stringify(deliverBody)}`); - const deliverResp = await axios.post(`${DINGTALK_API}/v1.0/card/instances/deliver`, deliverBody, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - }); - log?.info?.(`[DingTalk][AICard] 投放卡片响应: status=${deliverResp.status}`); - - return { cardInstanceId, accessToken: token, inputingStarted: false }; - } catch (err: any) { - log?.error?.(`[DingTalk][AICard] 创建卡片失败 (${targetDesc}): ${err.message}`); - if (err.response) { - log?.error?.(`[DingTalk][AICard] 错误响应: status=${err.response.status} data=${JSON.stringify(err.response.data)}`); - } - return null; - } -} - -/** - * 主动发送文件消息(使用普通消息 API) - */ -async function sendFileProactive( - config: any, - target: AICardTarget, - fileInfo: FileInfo, - mediaId: string, - log?: any, -): Promise { - try { - const token = await getAccessToken(config); - - // 钉钉普通消息 API 的文件消息格式 - const msgParam = { - mediaId: mediaId, - fileName: fileInfo.fileName, - fileType: fileInfo.fileType, - }; - - const body: any = { - robotCode: config.clientId, - msgKey: 'sampleFile', - msgParam: JSON.stringify(msgParam), - }; - - let endpoint: string; - if (target.type === 'group') { - body.openConversationId = target.openConversationId; - endpoint = `${DINGTALK_API}/v1.0/robot/groupMessages/send`; - } else { - body.userIds = [target.userId]; - endpoint = `${DINGTALK_API}/v1.0/robot/oToMessages/batchSend`; - } - - log?.info?.(`[DingTalk][File][Proactive] 发送文件消息: ${fileInfo.fileName}`); - const resp = await axios.post(endpoint, body, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - timeout: 10_000, - }); - - if (resp.data?.processQueryKey) { - log?.info?.(`[DingTalk][File][Proactive] 文件消息发送成功: ${fileInfo.fileName}`); - } else { - log?.warn?.(`[DingTalk][File][Proactive] 文件消息发送响应异常: ${JSON.stringify(resp.data)}`); - } - } catch (err: any) { - log?.error?.(`[DingTalk][File][Proactive] 发送文件消息失败: ${fileInfo.fileName}, 错误: ${err.message}`); - } -} - -/** - * 主动发送音频消息(使用普通消息 API) - */ -async function sendAudioProactive( - config: any, - target: AICardTarget, - fileInfo: FileInfo, - mediaId: string, - log?: any, - durationMs?: number, -): Promise { - try { - const token = await getAccessToken(config); - - // 钉钉普通消息 API 的音频消息格式 - const actualDuration = (durationMs && durationMs > 0) ? durationMs.toString() : '60000'; - const msgParam = { - mediaId: mediaId, - duration: actualDuration, - }; - - const body: any = { - robotCode: config.clientId, - msgKey: 'sampleAudio', - msgParam: JSON.stringify(msgParam), - }; - - let endpoint: string; - if (target.type === 'group') { - body.openConversationId = target.openConversationId; - endpoint = `${DINGTALK_API}/v1.0/robot/groupMessages/send`; - } else { - body.userIds = [target.userId]; - endpoint = `${DINGTALK_API}/v1.0/robot/oToMessages/batchSend`; - } - - log?.info?.(`[DingTalk][Audio][Proactive] 发送音频消息: ${fileInfo.fileName}`); - const resp = await axios.post(endpoint, body, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - timeout: 10_000, - }); - - if (resp.data?.processQueryKey) { - log?.info?.(`[DingTalk][Audio][Proactive] 音频消息发送成功: ${fileInfo.fileName}`); - } else { - log?.warn?.(`[DingTalk][Audio][Proactive] 音频消息发送响应异常: ${JSON.stringify(resp.data)}`); - } - } catch (err: any) { - log?.error?.(`[DingTalk][Audio][Proactive] 发送音频消息失败: ${fileInfo.fileName}, 错误: ${err.message}`); - } -} - -/** - * 主动发送视频消息(使用普通消息 API) - */ -async function sendVideoProactive( - config: any, - target: AICardTarget, - videoMediaId: string, - picMediaId: string, - metadata: VideoMetadata, - log?: any, -): Promise { - try { - const token = await getAccessToken(config); - - // 钉钉普通消息 API 的视频消息格式 - const msgParam = { - duration: metadata.duration.toString(), - videoMediaId: videoMediaId, - videoType: 'mp4', - picMediaId: picMediaId, - }; - - const body: any = { - robotCode: config.clientId, - msgKey: 'sampleVideo', - msgParam: JSON.stringify(msgParam), - }; - - let endpoint: string; - if (target.type === 'group') { - body.openConversationId = target.openConversationId; - endpoint = `${DINGTALK_API}/v1.0/robot/groupMessages/send`; - } else { - body.userIds = [target.userId]; - endpoint = `${DINGTALK_API}/v1.0/robot/oToMessages/batchSend`; - } - - log?.info?.(`[DingTalk][Video][Proactive] 发送视频消息`); - const resp = await axios.post(endpoint, body, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - timeout: 10_000, - }); - - if (resp.data?.processQueryKey) { - log?.info?.(`[DingTalk][Video][Proactive] 视频消息发送成功`); - } else { - log?.warn?.(`[DingTalk][Video][Proactive] 视频消息发送响应异常: ${JSON.stringify(resp.data)}`); - } - } catch (err: any) { - log?.error?.(`[DingTalk][Video][Proactive] 发送视频消息失败: ${err.message}`); - } -} - -/** 音频信息接口 */ -interface AudioInfo { - path: string; -} - -/** - * 提取音频标记并发送音频消息 - * 解析 [DINGTALK_AUDIO]{"path":"..."}[/DINGTALK_AUDIO] 标记 - * - * @param useProactiveApi 是否使用主动消息 API(用于 AI Card 场景) - * @param target 主动 API 需要的目标信息(useProactiveApi=true 时必须提供) - */ -async function processAudioMarkers( - content: string, - sessionWebhook: string, - config: any, - oapiToken: string | null, - log?: any, - useProactiveApi: boolean = false, - target?: AICardTarget, -): Promise { - const logPrefix = useProactiveApi ? '[DingTalk][Audio][Proactive]' : '[DingTalk][Audio]'; - - if (!oapiToken) { - log?.warn?.(`${logPrefix} 无 oapiToken,跳过音频处理`); - return content; - } - - const fs = await import('fs'); - const path = await import('path'); - - const matches = [...content.matchAll(AUDIO_MARKER_PATTERN)]; - const audioInfos: AudioInfo[] = []; - const invalidAudios: string[] = []; - - for (const match of matches) { - try { - const audioInfo = JSON.parse(match[1]) as AudioInfo; - if (audioInfo.path && fs.existsSync(audioInfo.path)) { - audioInfos.push(audioInfo); - log?.info?.(`${logPrefix} 提取到音频: ${audioInfo.path}`); - } else { - invalidAudios.push(audioInfo.path || '未知路径'); - log?.warn?.(`${logPrefix} 音频文件不存在: ${audioInfo.path}`); - } - } catch (err: any) { - log?.warn?.(`${logPrefix} 解析标记失败: ${err.message}`); - } - } - - if (audioInfos.length === 0 && invalidAudios.length === 0) { - log?.info?.(`${logPrefix} 未检测到音频标记`); - return content.replace(AUDIO_MARKER_PATTERN, '').trim(); - } - - // 先移除所有音频标记 - let cleanedContent = content.replace(AUDIO_MARKER_PATTERN, '').trim(); - - const statusMessages: string[] = []; - - for (const invalidPath of invalidAudios) { - statusMessages.push(`⚠️ 音频文件不存在: ${path.basename(invalidPath)}`); - } - - if (audioInfos.length > 0) { - log?.info?.(`${logPrefix} 检测到 ${audioInfos.length} 个音频,开始处理...`); - } - - for (const audioInfo of audioInfos) { - const fileName = path.basename(audioInfo.path); - try { - const ext = path.extname(audioInfo.path).slice(1).toLowerCase(); - - const fileInfo: FileInfo = { - path: audioInfo.path, - fileName: fileName, - fileType: ext, - }; - - // 上传音频到钉钉 - const mediaId = await uploadMediaToDingTalk(audioInfo.path, 'voice', oapiToken, 20 * 1024 * 1024, log); - if (!mediaId) { - statusMessages.push(`⚠️ 音频上传失败: ${fileName}(文件可能超过 20MB 限制)`); - continue; - } - - // 提取音频实际时长 - const audioDurationMs = await extractAudioDuration(audioInfo.path, log); - - // 发送音频消息 - if (useProactiveApi && target) { - await sendAudioProactive(config, target, fileInfo, mediaId, log, audioDurationMs ?? undefined); - } else { - await sendAudioMessage(config, sessionWebhook, fileInfo, mediaId, oapiToken, log, audioDurationMs ?? undefined); - } - statusMessages.push(`✅ 音频已发送: ${fileName}`); - log?.info?.(`${logPrefix} 音频处理完成: ${fileName}`); - } catch (err: any) { - log?.error?.(`${logPrefix} 处理音频失败: ${err.message}`); - statusMessages.push(`⚠️ 音频处理异常: ${fileName}(${err.message})`); - } - } - - if (statusMessages.length > 0) { - const statusText = statusMessages.join('\n'); - cleanedContent = cleanedContent - ? `${cleanedContent}\n\n${statusText}` - : statusText; - } - - return cleanedContent; -} - -/** - * 主动创建并发送 AI Card(通用内部实现) - * 复用 createAICardForTarget 并完整支持后处理 - * @param config 钉钉配置 - * @param target 投放目标(单聊或群聊) - * @param content 消息内容 - * @param log 日志对象 - * @returns SendResult - */ -async function sendAICardInternal( - config: any, - target: AICardTarget, - content: string, - log?: any, -): Promise { - const targetDesc = target.type === 'group' - ? `群聊 ${target.openConversationId}` - : `用户 ${target.userId}`; - - try { - // 0. 获取 oapiToken 用于后处理 - const oapiToken = await getOapiAccessToken(config); - - // 1. 后处理01:上传本地图片到钉钉,替换路径为 media_id - let processedContent = content; - if (oapiToken) { - log?.info?.(`[DingTalk][AICard][Proactive] 开始图片后处理`); - processedContent = await processLocalImages(content, oapiToken, log); - } else { - log?.warn?.(`[DingTalk][AICard][Proactive] 无法获取 oapiToken,跳过媒体后处理`); - } - - // 2. 后处理02:提取视频标记并发送视频消息 - log?.info?.(`[DingTalk][Video][Proactive] 开始视频后处理`); - processedContent = await processVideoMarkers(processedContent, '', config, oapiToken, log, true, target); - - // 3. 后处理03:提取音频标记并发送音频消息(使用主动消息 API) - log?.info?.(`[DingTalk][Audio][Proactive] 开始音频后处理`); - processedContent = await processAudioMarkers(processedContent, '', config, oapiToken, log, true, target); - - // 4. 后处理04:提取文件标记并发送独立文件消息(使用主动消息 API) - log?.info?.(`[DingTalk][File][Proactive] 开始文件后处理`); - processedContent = await processFileMarkers(processedContent, '', config, oapiToken, log, true, target); - - // 5. 检查处理后的内容是否为空(纯文件/视频/音频消息场景) - // 如果内容只包含文件/视频/音频标记,处理后会变成空字符串,此时跳过创建空白 AI Card - const trimmedContent = processedContent.trim(); - if (!trimmedContent) { - log?.info?.(`[DingTalk][AICard][Proactive] 处理后内容为空(纯文件/视频消息),跳过创建 AI Card`); - return { ok: true, usedAICard: false }; - } - - // 5. 创建卡片(复用通用函数) - const card = await createAICardForTarget(config, target, log); - if (!card) { - return { ok: false, error: 'Failed to create AI Card', usedAICard: false }; - } - - // 6. 使用 finishAICard 设置内容 - await finishAICard(card, processedContent, log); - - log?.info?.(`[DingTalk][AICard][Proactive] AI Card 发送成功: ${targetDesc}, cardInstanceId=${card.cardInstanceId}`); - return { ok: true, cardInstanceId: card.cardInstanceId, usedAICard: true }; - - } catch (err: any) { - log?.error?.(`[DingTalk][AICard][Proactive] AI Card 发送失败 (${targetDesc}): ${err.message}`); - if (err.response) { - log?.error?.(`[DingTalk][AICard][Proactive] 错误响应: status=${err.response.status} data=${JSON.stringify(err.response.data)}`); - } - return { ok: false, error: err.response?.data?.message || err.message, usedAICard: false }; - } -} - -/** - * 主动发送 AI Card 到单聊用户 - */ -async function sendAICardToUser( - config: any, - userId: string, - content: string, - log?: any, -): Promise { - return sendAICardInternal(config, { type: 'user', userId }, content, log); -} - -/** - * 主动发送 AI Card 到群聊 - */ -async function sendAICardToGroup( - config: any, - openConversationId: string, - content: string, - log?: any, -): Promise { - return sendAICardInternal(config, { type: 'group', openConversationId }, content, log); -} - -/** - * 构建普通消息的 msgKey 和 msgParam - * 提取公共逻辑,供 sendNormalToUser 和 sendNormalToGroup 复用 - */ -function buildMsgPayload( - msgType: DingTalkMsgType, - content: string, - title?: string, -): { msgKey: string; msgParam: Record } | { error: string } { - switch (msgType) { - case 'markdown': - return { - msgKey: 'sampleMarkdown', - msgParam: { - title: title || content.split('\n')[0].replace(/^[#*\s\->]+/, '').slice(0, 20) || 'Message', - text: content, - }, - }; - case 'link': - try { - return { - msgKey: 'sampleLink', - msgParam: typeof content === 'string' ? JSON.parse(content) : content, - }; - } catch { - return { error: 'Invalid link message format, expected JSON' }; - } - case 'actionCard': - try { - return { - msgKey: 'sampleActionCard', - msgParam: typeof content === 'string' ? JSON.parse(content) : content, - }; - } catch { - return { error: 'Invalid actionCard message format, expected JSON' }; - } - case 'image': - return { - msgKey: 'sampleImageMsg', - msgParam: { photoURL: content }, - }; - case 'text': - default: - return { - msgKey: 'sampleText', - msgParam: { content }, - }; - } -} - -/** - * 使用普通消息 API 发送单聊消息(降级方案) - */ -async function sendNormalToUser( - config: any, - userIds: string | string[], - content: string, - options: { msgType?: DingTalkMsgType; title?: string; log?: any } = {}, -): Promise { - const { msgType = 'text', title, log } = options; - const userIdArray = Array.isArray(userIds) ? userIds : [userIds]; - - // 构建消息参数 - const payload = buildMsgPayload(msgType, content, title); - if ('error' in payload) { - return { ok: false, error: payload.error, usedAICard: false }; - } - - try { - const token = await getAccessToken(config); - const body = { - robotCode: config.clientId, - userIds: userIdArray, - msgKey: payload.msgKey, - msgParam: JSON.stringify(payload.msgParam), - }; - - log?.info?.(`[DingTalk][Normal] 发送单聊消息: userIds=${userIdArray.join(',')}, msgType=${msgType}`); - - const resp = await axios.post(`${DINGTALK_API}/v1.0/robot/oToMessages/batchSend`, body, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - timeout: 10_000, - }); - - if (resp.data?.processQueryKey) { - log?.info?.(`[DingTalk][Normal] 发送成功: processQueryKey=${resp.data.processQueryKey}`); - return { ok: true, processQueryKey: resp.data.processQueryKey, usedAICard: false }; - } - - log?.warn?.(`[DingTalk][Normal] 发送响应异常: ${JSON.stringify(resp.data)}`); - return { ok: false, error: resp.data?.message || 'Unknown error', usedAICard: false }; - } catch (err: any) { - const errMsg = err.response?.data?.message || err.message; - log?.error?.(`[DingTalk][Normal] 发送失败: ${errMsg}`); - return { ok: false, error: errMsg, usedAICard: false }; - } -} - -/** - * 使用普通消息 API 发送群聊消息(降级方案) - */ -async function sendNormalToGroup( - config: any, - openConversationId: string, - content: string, - options: { msgType?: DingTalkMsgType; title?: string; log?: any } = {}, -): Promise { - const { msgType = 'text', title, log } = options; - - // 构建消息参数 - const payload = buildMsgPayload(msgType, content, title); - if ('error' in payload) { - return { ok: false, error: payload.error, usedAICard: false }; - } - - try { - const token = await getAccessToken(config); - const body = { - robotCode: config.clientId, - openConversationId, - msgKey: payload.msgKey, - msgParam: JSON.stringify(payload.msgParam), - }; - - log?.info?.(`[DingTalk][Normal] 发送群聊消息: openConversationId=${openConversationId}, msgType=${msgType}`); - - const resp = await axios.post(`${DINGTALK_API}/v1.0/robot/groupMessages/send`, body, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - timeout: 10_000, - }); - - if (resp.data?.processQueryKey) { - log?.info?.(`[DingTalk][Normal] 发送成功: processQueryKey=${resp.data.processQueryKey}`); - return { ok: true, processQueryKey: resp.data.processQueryKey, usedAICard: false }; - } - - log?.warn?.(`[DingTalk][Normal] 发送响应异常: ${JSON.stringify(resp.data)}`); - return { ok: false, error: resp.data?.message || 'Unknown error', usedAICard: false }; - } catch (err: any) { - const errMsg = err.response?.data?.message || err.message; - log?.error?.(`[DingTalk][Normal] 发送失败: ${errMsg}`); - return { ok: false, error: errMsg, usedAICard: false }; - } -} - -/** - * 主动发送单聊消息给指定用户 - * 默认使用 AI Card,失败时降级到普通消息 - * @param config 钉钉配置(需包含 clientId 和 clientSecret) - * @param userIds 用户 ID 数组(staffId 或 unionId) - * @param content 消息内容 - * @param options 可选配置 - */ -async function sendToUser( - config: any, - userIds: string | string[], - content: string, - options: ProactiveSendOptions = {}, -): Promise { - const { log, useAICard = true, fallbackToNormal = true } = options; - - if (!config.clientId || !config.clientSecret) { - return { ok: false, error: 'Missing clientId or clientSecret', usedAICard: false }; - } - - const userIdArray = Array.isArray(userIds) ? userIds : [userIds]; - if (userIdArray.length === 0) { - return { ok: false, error: 'userIds cannot be empty', usedAICard: false }; - } - - // AI Card 只支持单个用户 - if (useAICard && userIdArray.length === 1) { - log?.info?.(`[DingTalk][SendToUser] 尝试使用 AI Card 发送: userId=${userIdArray[0]}`); - const cardResult = await sendAICardToUser(config, userIdArray[0], content, log); - - if (cardResult.ok) { - return cardResult; - } - - // AI Card 失败 - log?.warn?.(`[DingTalk][SendToUser] AI Card 发送失败: ${cardResult.error}`); - - if (!fallbackToNormal) { - log?.error?.(`[DingTalk][SendToUser] 不降级到普通消息,返回错误`); - return cardResult; - } - - log?.info?.(`[DingTalk][SendToUser] 降级到普通消息发送`); - } else if (useAICard && userIdArray.length > 1) { - log?.info?.(`[DingTalk][SendToUser] 多用户发送不支持 AI Card,使用普通消息`); - } - - // 使用普通消息 - return sendNormalToUser(config, userIdArray, content, options); -} - -/** - * 主动发送群聊消息到指定群 - * 默认使用 AI Card,失败时降级到普通消息 - * @param config 钉钉配置(需包含 clientId 和 clientSecret) - * @param openConversationId 群会话 ID - * @param content 消息内容 - * @param options 可选配置 - */ -async function sendToGroup( - config: any, - openConversationId: string, - content: string, - options: ProactiveSendOptions = {}, -): Promise { - const { log, useAICard = true, fallbackToNormal = true } = options; - - if (!config.clientId || !config.clientSecret) { - return { ok: false, error: 'Missing clientId or clientSecret', usedAICard: false }; - } - - if (!openConversationId) { - return { ok: false, error: 'openConversationId cannot be empty', usedAICard: false }; - } - - // 尝试使用 AI Card - if (useAICard) { - log?.info?.(`[DingTalk][SendToGroup] 尝试使用 AI Card 发送: openConversationId=${openConversationId}`); - const cardResult = await sendAICardToGroup(config, openConversationId, content, log); - - if (cardResult.ok) { - return cardResult; - } - - // AI Card 失败 - log?.warn?.(`[DingTalk][SendToGroup] AI Card 发送失败: ${cardResult.error}`); - - if (!fallbackToNormal) { - log?.error?.(`[DingTalk][SendToGroup] 不降级到普通消息,返回错误`); - return cardResult; - } - - log?.info?.(`[DingTalk][SendToGroup] 降级到普通消息发送`); - } - - // 使用普通消息 - return sendNormalToGroup(config, openConversationId, content, options); -} - -/** - * 智能发送消息 - * 默认使用 AI Card,失败时降级到普通消息 - * @param config 钉钉配置 - * @param target 目标:{ userId } 或 { openConversationId } - * @param content 消息内容 - * @param options 可选配置 - */ -async function sendProactive( - config: any, - target: { userId?: string; userIds?: string[]; openConversationId?: string }, - content: string, - options: ProactiveSendOptions = {}, -): Promise { - // 自动检测是否使用 markdown(用于降级时) - if (!options.msgType) { - const hasMarkdown = /^[#*>-]|[*_`#\[\]]/.test(content) || content.includes('\n'); - if (hasMarkdown) { - options.msgType = 'markdown'; - } - } - - // 发送到用户 - if (target.userId || target.userIds) { - const userIds = target.userIds || [target.userId!]; - return sendToUser(config, userIds, content, options); - } - - // 发送到群 - if (target.openConversationId) { - return sendToGroup(config, target.openConversationId, content, options); - } - - return { ok: false, error: 'Must specify userId, userIds, or openConversationId', usedAICard: false }; -} - -// ============ 核心消息处理 (AI Card Streaming) ============ - -async function handleDingTalkMessage(params: { - cfg: ClawdbotConfig; - accountId: string; - data: any; - sessionWebhook: string; - log?: any; - dingtalkConfig: any; -}): Promise { - const { cfg, accountId, data, sessionWebhook, log, dingtalkConfig } = params; - - const content = extractMessageContent(data); - if (!content.text && content.imageUrls.length === 0 && content.downloadCodes.length === 0) return; - - const isDirect = data.conversationType === '1'; - const senderId = data.senderStaffId || data.senderId; - const senderName = data.senderNick || 'Unknown'; - - log?.info?.(`[DingTalk] 收到消息: from=${senderName} type=${content.messageType} text="${content.text.slice(0, 50)}..." images=${content.imageUrls.length} downloadCodes=${content.downloadCodes.length}`); - - // ===== DM Policy 检查 ===== - if (isDirect) { - const dmPolicy = dingtalkConfig.dmPolicy || 'open'; - const allowFrom: string[] = dingtalkConfig.allowFrom || []; - if (dmPolicy === 'allowlist' && allowFrom.length > 0 && !allowFrom.includes(senderId)) { - log?.warn?.(`[DingTalk] DM 被拦截: senderId=${senderId} 不在 allowFrom 白名单中`); - return; - } - } - - // ===== Session 管理 ===== - const forceNewSession = isNewSessionCommand(content.text); - - // 如果是新会话命令,直接回复确认消息 - if (forceNewSession) { - await sendMessage(dingtalkConfig, sessionWebhook, '✨ 已开启新会话,之前的对话已清空。', { - atUserId: !isDirect ? senderId : null, - }); - log?.info?.(`[DingTalk] 用户请求新会话: ${senderId}`); - return; - } - - // 构建 OpenClaw 标准会话上下文 - // 兼容旧配置:sessionTimeout 已废弃,打印警告 - if (dingtalkConfig.sessionTimeout !== undefined) { - log?.warn?.(`[DingTalk][Deprecation] 'sessionTimeout' 配置已废弃,会话超时由 OpenClaw Gateway 的 session.reset 配置控制`); - } - const separateSessionByConversation = dingtalkConfig.separateSessionByConversation as boolean | undefined; - const groupSessionScope = dingtalkConfig.groupSessionScope as 'group' | 'group_sender' | undefined; - const sessionContext = buildSessionContext({ - accountId, - senderId, - senderName, - conversationType: data.conversationType, - conversationId: data.conversationId, - groupSubject: data.conversationTitle, - separateSessionByConversation, - groupSessionScope, - }); - const sessionContextJson = JSON.stringify(sessionContext); - log?.info?.(`[DingTalk][Session] context=${sessionContextJson}`); - - // memoryUser 用于 Gateway 区分记忆归属 - // 使用 peerId(不包含中文)作为标识符,避免 HTTP Header 编码问题 - const memoryUser = dingtalkConfig.sharedMemoryAcrossConversations === true - ? accountId - : `${sessionContext.channel}:${sessionContext.accountId}:${sessionContext.peerId}`; - - // Gateway 认证:优先使用 token,其次 password - const gatewayAuth = dingtalkConfig.gatewayToken || dingtalkConfig.gatewayPassword || ''; - - // 构建 system prompts & 获取 oapi token(用于图片和文件后处理) - const systemPrompts: string[] = []; - let oapiToken: string | null = null; - - if (dingtalkConfig.enableMediaUpload !== false) { - // 添加图片和文件使用提示(告诉 LLM 直接输出本地路径或文件标记) - systemPrompts.push(buildMediaSystemPrompt()); - // 获取 token 用于后处理上传 - oapiToken = await getOapiAccessToken(dingtalkConfig); - log?.info?.(`[DingTalk][Media] oapiToken 获取${oapiToken ? '成功' : '失败'}`); - } else { - log?.info?.(`[DingTalk][Media] enableMediaUpload=false,跳过`); - } - - // 自定义 system prompt - if (dingtalkConfig.systemPrompt) { - systemPrompts.push(dingtalkConfig.systemPrompt); - } - - // ===== 图片下载到本地文件(用于 OpenClaw AgentMediaPayload) ===== - const imageLocalPaths: string[] = []; - - // 处理直接图片 URL(来自 richText 的 pictureUrl) - for (const url of content.imageUrls) { - if (url.startsWith('downloadCode:')) { - // 通过 downloadCode 下载 - const code = url.slice('downloadCode:'.length); - const localPath = await downloadMediaByCode(code, dingtalkConfig, log); - if (localPath) imageLocalPaths.push(localPath); - } else { - // 直接 URL 下载 - const localPath = await downloadImageToFile(url, log); - if (localPath) imageLocalPaths.push(localPath); - } - } - - // 处理 downloadCode(来自 picture 消息,fileNames 为空的是图片) - for (let i = 0; i < content.downloadCodes.length; i++) { - const code = content.downloadCodes[i]; - const fileName = content.fileNames[i]; // 有 fileName 说明是文件,否则是图片 - if (!fileName) { - const localPath = await downloadMediaByCode(code, dingtalkConfig, log); - if (localPath) imageLocalPaths.push(localPath); - } - } - - if (imageLocalPaths.length > 0) { - log?.info?.(`[DingTalk][Image] 成功下载 ${imageLocalPaths.length} 张图片到本地`); - } - - // ===== 文件附件下载与内容提取 ===== - const fileContentParts: string[] = []; - for (let i = 0; i < content.downloadCodes.length; i++) { - const code = content.downloadCodes[i]; - const fileName = content.fileNames[i]; - if (!fileName) continue; // 图片已在上面处理 - - const ext = path.extname(fileName).toLowerCase(); - const localPath = await downloadFileByCode(code, fileName, dingtalkConfig, log); - - if (!localPath) { - fileContentParts.push(`[文件下载失败: ${fileName}]`); - continue; - } - - if (TEXT_FILE_EXTENSIONS.has(ext)) { - // 文本类文件:读取内容追加到消息 - try { - const fileContent = fs.readFileSync(localPath, 'utf-8'); - const maxLen = 50_000; // 限制最大读取长度 - const truncated = fileContent.length > maxLen ? fileContent.slice(0, maxLen) + '\n...(内容过长,已截断)' : fileContent; - fileContentParts.push(`[文件: ${fileName}]\n\`\`\`\n${truncated}\n\`\`\``); - log?.info?.(`[DingTalk][File] 文本文件已读取: ${fileName}, size=${fileContent.length}`); - } catch (err: any) { - log?.error?.(`[DingTalk][File] 读取文本文件失败: ${err.message}`); - fileContentParts.push(`[文件已保存: ${localPath},但读取内容失败]`); - } - } else if (ext === '.docx') { - // Word 文档:用 mammoth 提取纯文本 - try { - const mammoth = await import('mammoth'); - const result = await mammoth.default.extractRawText({ path: localPath }); - const fileContent = result.value; - const maxLen = 50_000; - const truncated = fileContent.length > maxLen ? fileContent.slice(0, maxLen) + '\n...(内容过长,已截断)' : fileContent; - fileContentParts.push(`[文件: ${fileName}]\n\`\`\`\n${truncated}\n\`\`\``); - log?.info?.(`[DingTalk][File] Word 文档已提取文本: ${fileName}, size=${fileContent.length}`); - } catch (err: any) { - log?.error?.(`[DingTalk][File] Word 文档文本提取失败: ${err.message}`); - fileContentParts.push(`[文件已保存: ${localPath},但提取文本失败]`); - } - } else if (ext === '.pdf') { - // PDF 文档:用 pdf-parse 提取纯文本 - try { - const pdfParse = (await import('pdf-parse')).default; - const dataBuffer = fs.readFileSync(localPath); - const pdfData = await pdfParse(dataBuffer); - const fileContent = pdfData.text; - const maxLen = 50_000; - const truncated = fileContent.length > maxLen ? fileContent.slice(0, maxLen) + '\n...(内容过长,已截断)' : fileContent; - fileContentParts.push(`[文件: ${fileName}]\n\`\`\`\n${truncated}\n\`\`\``); - log?.info?.(`[DingTalk][File] PDF 文档已提取文本: ${fileName}, size=${fileContent.length}`); - } catch (err: any) { - log?.error?.(`[DingTalk][File] PDF 文档文本提取失败: ${err.message}`); - fileContentParts.push(`[文件已保存: ${localPath},但提取文本失败]`); - } - } else { - // Office/二进制文件:保存到本地,提示路径 - fileContentParts.push(`[文件已保存: ${localPath},请基于文件名和上下文回答]`); - log?.info?.(`[DingTalk][File] 文件已保存: ${fileName} -> ${localPath}`); - } - } - - // 对于纯图片消息(无文本),添加默认提示 - let userContent = content.text || (imageLocalPaths.length > 0 ? '请描述这张图片' : ''); - // 追加文件内容 - if (fileContentParts.length > 0) { - const fileText = fileContentParts.join('\n\n'); - userContent = userContent ? `${userContent}\n\n${fileText}` : fileText; - } - if (!userContent && imageLocalPaths.length === 0) return; - - // ===== 异步模式:立即回执 + 后台执行 + 主动推送结果 ===== - const asyncMode = dingtalkConfig.asyncMode === true; - const proactiveTarget = isDirect - ? { userId: data.senderStaffId || data.senderId } - : { openConversationId: data.conversationId }; - - if (asyncMode) { - const ackText = dingtalkConfig.ackText || '🫡 任务已接收,处理中...'; - try { - await sendProactive(dingtalkConfig, proactiveTarget, ackText, { - msgType: 'text', - useAICard: false, - fallbackToNormal: true, - log, - }); - } catch (ackErr: any) { - log?.warn?.(`[DingTalk][Async] 回执发送失败: ${ackErr?.message || ackErr}`); - } - - // 计算 peerKind 和 peerId 用于 bindings 匹配 - const peerKind: 'direct' | 'group' = isDirect ? 'direct' : 'group'; - const peerId = senderId; - - let fullResponse = ''; - try { - for await (const chunk of streamFromGateway({ - userContent, - systemPrompts, - sessionKey: sessionContextJson, - gatewayAuth, - memoryUser, - imageLocalPaths: imageLocalPaths.length > 0 ? imageLocalPaths : undefined, - peerKind, - peerId, - gatewayPort: cfg.gateway?.port, - log, - }, accountId)) { - fullResponse += chunk; - } - - log?.info?.(`[DingTalk][Async] Gateway 完成,原始长度=${fullResponse.length}`); - - // 后处理01:上传本地图片到钉钉,替换 file:// 路径为 media_id - fullResponse = await processLocalImages(fullResponse, oapiToken, log); - - // 后处理02:提取视频标记并发送视频消息(主动 API) - const proactiveMediaTarget: AICardTarget = isDirect - ? { type: 'user', userId: data.senderStaffId || data.senderId } - : { type: 'group', openConversationId: data.conversationId }; - fullResponse = await processVideoMarkers(fullResponse, '', dingtalkConfig, oapiToken, log, true, proactiveMediaTarget); - - // 后处理03:提取音频标记并发送音频消息(主动 API) - fullResponse = await processAudioMarkers(fullResponse, '', dingtalkConfig, oapiToken, log, true, proactiveMediaTarget); - - // 后处理04:提取文件标记并发送独立文件消息(主动 API) - fullResponse = await processFileMarkers(fullResponse, '', dingtalkConfig, oapiToken, log, true, proactiveMediaTarget); - - const finalText = fullResponse.trim() || '✅ 任务执行完成(无文本输出)'; - await sendProactive(dingtalkConfig, proactiveTarget, finalText, { - msgType: 'markdown', - useAICard: false, - fallbackToNormal: true, - log, - }); - - log?.info?.(`[DingTalk][Async] 结果已主动推送,长度=${finalText.length}`); - } catch (err: any) { - const errMsg = `⚠️ 任务执行失败: ${err?.message || err}`; - log?.error?.(`[DingTalk][Async] ${errMsg}`); - try { - await sendProactive(dingtalkConfig, proactiveTarget, errMsg, { - msgType: 'text', - useAICard: false, - fallbackToNormal: true, - log, - }); - } catch (sendErr: any) { - log?.error?.(`[DingTalk][Async] 错误通知发送失败: ${sendErr?.message || sendErr}`); - } - } - - return; - } - - // 计算 peerKind 和 peerId 用于 bindings 匹配(在 asyncMode 外部定义,供所有分支使用) - const peerKind: 'direct' | 'group' = isDirect ? 'direct' : 'group'; - const peerId = senderId; - - // 尝试创建 AI Card - const card = await createAICard(dingtalkConfig, data, log); - - if (card) { - // ===== AI Card 流式模式 ===== - log?.info?.(`[DingTalk] AI Card 创建成功: ${card.cardInstanceId}`); - - let accumulated = ''; - let lastUpdateTime = 0; - const updateInterval = 300; // 最小更新间隔 ms - let chunkCount = 0; - - try { - log?.info?.(`[DingTalk] 开始请求 Gateway 流式接口...`); - for await (const chunk of streamFromGateway({ - userContent, - systemPrompts, - sessionKey: sessionContextJson, - gatewayAuth, - memoryUser, - imageLocalPaths: imageLocalPaths.length > 0 ? imageLocalPaths : undefined, - peerKind, - peerId, - gatewayPort: cfg.gateway?.port, - log, - }, accountId)) { - accumulated += chunk; - chunkCount++; - - if (chunkCount <= 3) { - log?.info?.(`[DingTalk] Gateway chunk #${chunkCount}: "${chunk.slice(0, 50)}..." (accumulated=${accumulated.length})`); - } - - // 节流更新,避免过于频繁 - const now = Date.now(); - if (now - lastUpdateTime >= updateInterval) { - // 实时清理文件、视频、音频标记(避免用户在流式过程中看到标记) - const displayContent = accumulated - .replace(FILE_MARKER_PATTERN, '') - .replace(VIDEO_MARKER_PATTERN, '') - .replace(AUDIO_MARKER_PATTERN, '') - .trim(); - await streamAICard(card, displayContent, false, log); - lastUpdateTime = now; - } - } - - log?.info?.(`[DingTalk] Gateway 流完成,共 ${chunkCount} chunks, ${accumulated.length} 字符`); - - // 后处理01:上传本地图片到钉钉,替换 file:// 路径为 media_id - log?.info?.(`[DingTalk][Media] 开始图片后处理,内容片段="${accumulated.slice(0, 200)}..."`); - accumulated = await processLocalImages(accumulated, oapiToken, log); - - // 【关键修复】AI Card 场景使用主动消息 API 发送文件/视频,避免 sessionWebhook 失效问题 - // 构建目标信息用于主动 API(isDirect 已在上面定义) - const proactiveTarget: AICardTarget = isDirect - ? { type: 'user', userId: data.senderStaffId || data.senderId } - : { type: 'group', openConversationId: data.conversationId }; - - // 后处理02:提取视频标记并发送视频消息(使用主动消息 API) - log?.info?.(`[DingTalk][Video] 开始视频后处理 (使用主动API)`); - accumulated = await processVideoMarkers(accumulated, '', dingtalkConfig, oapiToken, log, true, proactiveTarget); - - // 后处理03:提取音频标记并发送音频消息(使用主动消息 API) - log?.info?.(`[DingTalk][Audio] 开始音频后处理 (使用主动API)`); - accumulated = await processAudioMarkers(accumulated, '', dingtalkConfig, oapiToken, log, true, proactiveTarget); - - // 后处理04:提取文件标记并发送独立文件消息(使用主动消息 API) - log?.info?.(`[DingTalk][File] 开始文件后处理 (使用主动API,目标=${JSON.stringify(proactiveTarget)})`); - accumulated = await processFileMarkers(accumulated, sessionWebhook, dingtalkConfig, oapiToken, log, true, proactiveTarget); - - // 完成 AI Card(如果内容为空,说明是纯媒体消息,使用默认提示) - const finalContent = accumulated.trim(); - if (finalContent.length === 0) { - log?.info?.(`[DingTalk][AICard] 内容为空(纯媒体消息),使用默认提示`); - await finishAICard(card, '✅ 媒体已发送', log); - } else { - await finishAICard(card, finalContent, log); - } - log?.info?.(`[DingTalk] 流式响应完成,共 ${finalContent.length} 字符`); - - } catch (err: any) { - log?.error?.(`[DingTalk] Gateway 调用失败: ${err.message}`); - log?.error?.(`[DingTalk] 错误详情: ${err.stack}`); - accumulated += `\n\n⚠️ 响应中断: ${err.message}`; - try { - await finishAICard(card, accumulated, log); - } catch (finishErr: any) { - log?.error?.(`[DingTalk] 错误恢复 finish 也失败: ${finishErr.message}`); - } - } - - } else { - // ===== 降级:普通消息模式 ===== - log?.warn?.(`[DingTalk] AI Card 创建失败,降级为普通消息`); - - let fullResponse = ''; - try { - for await (const chunk of streamFromGateway({ - userContent, - systemPrompts, - sessionKey: sessionContextJson, - gatewayAuth, - memoryUser, - imageLocalPaths: imageLocalPaths.length > 0 ? imageLocalPaths : undefined, - peerKind, - peerId, - gatewayPort: cfg.gateway?.port, - log, - }, accountId)) { - fullResponse += chunk; - } - - // 后处理01:上传本地图片到钉钉,替换 file:// 路径为 media_id - log?.info?.(`[DingTalk][Media] (降级模式) 开始图片后处理,内容片段="${fullResponse.slice(0, 200)}..."`); - fullResponse = await processLocalImages(fullResponse, oapiToken, log); - - // 后处理02:提取视频标记并发送视频消息 - log?.info?.(`[DingTalk][Video] (降级模式) 开始视频后处理`); - fullResponse = await processVideoMarkers(fullResponse, sessionWebhook, dingtalkConfig, oapiToken, log); - - // 后处理03:提取音频标记并发送音频消息 - log?.info?.(`[DingTalk][Audio] (降级模式) 开始音频后处理`); - fullResponse = await processAudioMarkers(fullResponse, sessionWebhook, dingtalkConfig, oapiToken, log); - - // 后处理04:提取文件标记并发送独立文件消息 - log?.info?.(`[DingTalk][File] (降级模式) 开始文件后处理`); - fullResponse = await processFileMarkers(fullResponse, sessionWebhook, dingtalkConfig, oapiToken, log); - - await sendMessage(dingtalkConfig, sessionWebhook, fullResponse || '(无响应)', { - atUserId: !isDirect ? senderId : null, - useMarkdown: true, - }); - log?.info?.(`[DingTalk] 普通消息回复完成,共 ${fullResponse.length} 字符`); - - } catch (err: any) { - log?.error?.(`[DingTalk] Gateway 调用失败: ${err.message}`); - await sendMessage(dingtalkConfig, sessionWebhook, `抱歉,处理请求时出错: ${err.message}`, { - atUserId: !isDirect ? senderId : null, - }); - } - } -} - -// ============ 钉钉文档 API ============ - -/** 文档信息接口 */ -interface DocInfo { - docId: string; - title: string; - docType: string; - creatorId?: string; - updatedAt?: string; -} - -/** 文档内容块 */ -interface DocBlock { - blockId: string; - blockType: string; - text?: string; - children?: DocBlock[]; -} - -/** - * 钉钉文档客户端 - * 支持读写钉钉在线文档(文档、表格等) - */ -class DingtalkDocsClient { - private config: any; - private log?: any; - - constructor(config: any, log?: any) { - this.config = config; - this.log = log; - } - - /** 获取带鉴权的请求头 */ - private async getHeaders(): Promise> { - const token = await getAccessToken(this.config); - return { - 'x-acs-dingtalk-access-token': token, - 'Content-Type': 'application/json', - }; - } - - /** - * 获取文档元信息 - * @param spaceId 空间 ID - * @param docId 文档 ID - */ - async getDocInfo(spaceId: string, docId: string): Promise { - try { - const headers = await this.getHeaders(); - this.log?.info?.(`[DingTalk][Docs] 获取文档信息: spaceId=${spaceId}, docId=${docId}`); - - const resp = await axios.get( - `${DINGTALK_API}/v1.0/doc/spaces/${spaceId}/docs/${docId}`, - { headers, timeout: 10_000 }, - ); - - const data = resp.data; - this.log?.info?.(`[DingTalk][Docs] 文档信息获取成功: title=${data?.title}`); - - return { - docId: data.docId || docId, - title: data.title || '', - docType: data.docType || 'unknown', - creatorId: data.creatorId, - updatedAt: data.updatedAt, - }; - } catch (err: any) { - this.log?.error?.(`[DingTalk][Docs] 获取文档信息失败: ${err.message}`); - return null; - } - } - - /** - * 读取文档内容(通过 v2.0/wiki 节点 API) - * @param nodeId 知识库节点 ID - * @param operatorId 操作者 unionId(必须) - */ - async readDoc(nodeId: string, operatorId?: string): Promise { - try { - const headers = await this.getHeaders(); - this.log?.info?.(`[DingTalk][Docs] 读取知识库节点: nodeId=${nodeId}, operatorId=${operatorId}`); - - if (!operatorId) { - this.log?.error?.('[DingTalk][Docs] readDoc 需要 operatorId(unionId)'); - return null; - } - - const resp = await axios.get( - `${DINGTALK_API}/v2.0/wiki/nodes/${nodeId}`, - { headers, params: { operatorId }, timeout: 15_000 }, - ); - - const node = resp.data?.node || resp.data; - const name = node.name || '未知文档'; - const category = node.category || 'unknown'; - const url = node.url || ''; - const workspaceId = node.workspaceId || ''; - - const content = [ - `文档名: ${name}`, - `类型: ${category}`, - `URL: ${url}`, - `工作区: ${workspaceId}`, - ].join('\n'); - - this.log?.info?.(`[DingTalk][Docs] 节点信息获取成功: name=${name}, category=${category}`); - return content; - } catch (err: any) { - this.log?.error?.(`[DingTalk][Docs] 读取节点失败: ${err.message}`); - if (err.response) { - this.log?.error?.(`[DingTalk][Docs] 错误详情: status=${err.response.status} data=${JSON.stringify(err.response.data)}`); - } - return null; - } - } - - /** - * 从 block 树中递归提取纯文本内容 - */ - private extractTextFromBlocks(blocks: DocBlock[]): string[] { - const result: string[] = []; - for (const block of blocks) { - if (block.text) { - result.push(block.text); - } - if (block.children && block.children.length > 0) { - result.push(...this.extractTextFromBlocks(block.children)); - } - } - return result; - } - - /** - * 向文档追加内容 - * @param docId 文档 ID - * @param content 要追加的文本内容 - * @param index 插入位置(-1 表示末尾) - */ - async appendToDoc( - docId: string, - content: string, - index: number = -1, - ): Promise { - try { - const headers = await this.getHeaders(); - this.log?.info?.(`[DingTalk][Docs] 向文档追加内容: docId=${docId}, contentLen=${content.length}`); - - const body = { - blockType: 'PARAGRAPH', - body: { - text: content, - }, - index, - }; - - await axios.post( - `${DINGTALK_API}/v1.0/doc/documents/${docId}/blocks/root/children`, - body, - { headers, timeout: 10_000 }, - ); - - this.log?.info?.(`[DingTalk][Docs] 内容追加成功`); - return true; - } catch (err: any) { - this.log?.error?.(`[DingTalk][Docs] 追加内容失败: ${err.message}`); - if (err.response) { - this.log?.error?.(`[DingTalk][Docs] 错误详情: status=${err.response.status} data=${JSON.stringify(err.response.data)}`); - } - return false; - } - } - - /** - * 创建新文档 - * @param spaceId 空间 ID - * @param title 文档标题 - * @param content 初始内容(可选) - */ - async createDoc( - spaceId: string, - title: string, - content?: string, - ): Promise { - try { - const headers = await this.getHeaders(); - this.log?.info?.(`[DingTalk][Docs] 创建文档: spaceId=${spaceId}, title=${title}`); - - const body: any = { - spaceId, - parentDentryId: '', - name: title, - docType: 'alidoc', - }; - - const resp = await axios.post( - `${DINGTALK_API}/v1.0/doc/spaces/${spaceId}/docs`, - body, - { headers, timeout: 10_000 }, - ); - - const data = resp.data; - this.log?.info?.(`[DingTalk][Docs] 文档创建成功: docId=${data?.docId}`); - - const docInfo: DocInfo = { - docId: data.docId || data.dentryUuid || '', - title: title, - docType: data.docType || 'alidoc', - }; - - // 如果有初始内容,追加到文档 - if (content && docInfo.docId) { - await this.appendToDoc(docInfo.docId, content); - } - - return docInfo; - } catch (err: any) { - this.log?.error?.(`[DingTalk][Docs] 创建文档失败: ${err.message}`); - if (err.response) { - this.log?.error?.(`[DingTalk][Docs] 错误详情: status=${err.response.status} data=${JSON.stringify(err.response.data)}`); - } - return null; - } - } - - /** - * 搜索文档 - * @param keyword 搜索关键词 - * @param spaceId 空间 ID(可选,不填则搜索所有空间) - */ - async searchDocs( - keyword: string, - spaceId?: string, - ): Promise { - try { - const headers = await this.getHeaders(); - this.log?.info?.(`[DingTalk][Docs] 搜索文档: keyword=${keyword}, spaceId=${spaceId || '全部'}`); - - const body: any = { keyword, maxResults: 20 }; - if (spaceId) body.spaceId = spaceId; - - const resp = await axios.post( - `${DINGTALK_API}/v1.0/doc/docs/search`, - body, - { headers, timeout: 10_000 }, - ); - - const items = resp.data?.items || []; - const docs: DocInfo[] = items.map((item: any) => ({ - docId: item.docId || item.dentryUuid || '', - title: item.name || item.title || '', - docType: item.docType || 'unknown', - creatorId: item.creatorId, - updatedAt: item.updatedAt, - })); - - this.log?.info?.(`[DingTalk][Docs] 搜索到 ${docs.length} 个文档`); - return docs; - } catch (err: any) { - this.log?.error?.(`[DingTalk][Docs] 搜索文档失败: ${err.message}`); - return []; - } - } - - /** - * 列出空间下的文档 - * @param spaceId 空间 ID - * @param parentId 父目录 ID(可选,不填则列出根目录) - */ - async listDocs( - spaceId: string, - parentId?: string, - ): Promise { - try { - const headers = await this.getHeaders(); - this.log?.info?.(`[DingTalk][Docs] 列出文档: spaceId=${spaceId}, parentId=${parentId || '根目录'}`); - - const params: any = { maxResults: 50 }; - if (parentId) params.parentDentryId = parentId; - - const resp = await axios.get( - `${DINGTALK_API}/v1.0/doc/spaces/${spaceId}/dentries`, - { headers, params, timeout: 10_000 }, - ); - - const items = resp.data?.items || []; - const docs: DocInfo[] = items.map((item: any) => ({ - docId: item.dentryUuid || item.docId || '', - title: item.name || '', - docType: item.docType || item.dentryType || 'unknown', - creatorId: item.creatorId, - updatedAt: item.updatedAt, - })); - - this.log?.info?.(`[DingTalk][Docs] 列出 ${docs.length} 个文档/目录`); - return docs; - } catch (err: any) { - this.log?.error?.(`[DingTalk][Docs] 列出文档失败: ${err.message}`); - return []; - } - } -} - -// ============ 插件定义 ============ - -const meta = { - id: 'dingtalk-connector', - label: 'DingTalk', - selectionLabel: 'DingTalk (钉钉)', - docsPath: '/channels/dingtalk-connector', - docsLabel: 'dingtalk-connector', - blurb: '钉钉企业内部机器人,使用 Stream 模式,无需公网 IP,支持 AI Card 流式响应。', - order: 70, - aliases: ['dd', 'ding'], -}; - -const dingtalkPlugin = { - id: 'dingtalk-connector', - meta, - capabilities: { - chatTypes: ['direct', 'group'], - reactions: false, - threads: false, - media: true, - nativeCommands: false, - blockStreaming: false, - }, - reload: { configPrefixes: ['channels.dingtalk-connector'] }, - configSchema: { - schema: { - type: 'object', - additionalProperties: false, - properties: { - enabled: { type: 'boolean', default: true }, - clientId: { type: 'string', description: 'DingTalk App Key (Client ID)' }, - clientSecret: { type: 'string', description: 'DingTalk App Secret (Client Secret)' }, - enableMediaUpload: { type: 'boolean', default: true, description: 'Enable media upload prompt injection' }, - systemPrompt: { type: 'string', default: '', description: 'Custom system prompt' }, - dmPolicy: { type: 'string', enum: ['open', 'pairing', 'allowlist'], default: 'open' }, - allowFrom: { type: 'array', items: { type: 'string' }, description: 'Allowed sender IDs' }, - groupPolicy: { type: 'string', enum: ['open', 'allowlist'], default: 'open' }, - gatewayToken: { type: 'string', default: '', description: 'Gateway auth token (Bearer)' }, - gatewayPassword: { type: 'string', default: '', description: 'Gateway auth password (alternative to token)' }, - sessionTimeout: { type: 'number', default: 1800000, description: 'Session timeout in ms (default 30min)' }, - separateSessionByConversation: { type: 'boolean', default: true, description: '是否按单聊/群聊/群区分 session' }, - sharedMemoryAcrossConversations: { type: 'boolean', default: false, description: '单 agent 场景下是否共享记忆;false 时不同群聊、群聊与私聊记忆隔离' }, - asyncMode: { type: 'boolean', default: false, description: 'Send immediate ack and push final result as a second message' }, - ackText: { type: 'string', default: '🫡 任务已接收,处理中...', description: 'Ack text when asyncMode is enabled' }, - debug: { type: 'boolean', default: false }, - }, - required: ['clientId', 'clientSecret'], - }, - uiHints: { - enabled: { label: 'Enable DingTalk' }, - clientId: { label: 'App Key', sensitive: false }, - clientSecret: { label: 'App Secret', sensitive: true }, - dmPolicy: { label: 'DM Policy' }, - groupPolicy: { label: 'Group Policy' }, - }, - }, - config: { - listAccountIds: (cfg: ClawdbotConfig) => { - const config = getConfig(cfg); - // __default__ 是内部标记,表示使用顶层配置(单账号模式) - return config.accounts - ? Object.keys(config.accounts) - : (isConfigured(cfg) ? ['__default__'] : []); - }, - resolveAccount: (cfg: ClawdbotConfig, accountId?: string) => { - const config = getConfig(cfg); - const id = accountId || DEFAULT_ACCOUNT_ID; - if (config.accounts?.[id]) { - return { accountId: id, config: config.accounts[id], enabled: config.accounts[id].enabled !== false }; - } - // 没有 accounts 配置或找不到指定账号时,使用顶层配置 - return { accountId: DEFAULT_ACCOUNT_ID, config, enabled: config.enabled !== false }; - }, - defaultAccountId: () => '__default__', - isConfigured: (account: any) => Boolean(account.config?.clientId && account.config?.clientSecret), - describeAccount: (account: any) => ({ - accountId: account.accountId, - name: account.config?.name || 'DingTalk', - enabled: account.enabled, - configured: Boolean(account.config?.clientId), - }), - }, - security: { - resolveDmPolicy: ({ account }: any) => ({ - policy: account.config?.dmPolicy || 'open', - allowFrom: account.config?.allowFrom || [], - policyPath: 'channels.dingtalk-connector.dmPolicy', - allowFromPath: 'channels.dingtalk-connector.allowFrom', - approveHint: '使用 /allow dingtalk-connector: 批准用户', - normalizeEntry: (raw: string) => raw.replace(/^(dingtalk-connector|dingtalk|dd|ding):/i, ''), - }), - }, - groups: { - resolveRequireMention: ({ cfg }: any) => getConfig(cfg).groupPolicy !== 'open', - }, - messaging: { - // 注意:normalizeTarget 接收字符串,返回字符串(保持大小写,因为 openConversationId 是 base64 编码) - normalizeTarget: (raw: string) => { - if (!raw) return undefined; - // 去掉渠道前缀,但保持原始大小写 - return raw.trim().replace(/^(dingtalk-connector|dingtalk|dd|ding):/i, ''); - }, - targetResolver: { - // 支持普通 ID、Base64 编码的 conversationId,以及 user:/group: 前缀格式 - looksLikeId: (id: string) => /^(user:|group:)?[\w+/=-]+$/.test(id), - hint: 'user: 或 group:', - }, - }, - outbound: { - deliveryMode: 'direct' as const, - textChunkLimit: 4000, - /** - * 主动发送文本消息 - * @param ctx.to 目标格式:user: 或 group: - * @param ctx.text 消息内容 - * @param ctx.accountId 账号 ID - */ - sendText: async (ctx: any) => { - const { cfg, to, text, accountId, log } = ctx; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - const config = account?.config; - - if (!config?.clientId || !config?.clientSecret) { - throw new Error('DingTalk not configured'); - } - - if (!to) { - throw new Error('Target is required. Format: user: or group:'); - } - - // 解析目标:user: 或 group: - const targetStr = String(to); - let result: SendResult; - - log?.info?.(`[DingTalk][outbound.sendText] 解析目标: targetStr="${targetStr}"`); - - if (targetStr.startsWith('user:')) { - const userId = targetStr.slice(5); - log?.info?.(`[DingTalk][outbound.sendText] 发送给用户: userId="${userId}"`); - result = await sendToUser(config, userId, text, { log }); - } else if (targetStr.startsWith('group:')) { - const openConversationId = targetStr.slice(6); - log?.info?.(`[DingTalk][outbound.sendText] 发送到群: openConversationId="${openConversationId}"`); - result = await sendToGroup(config, openConversationId, text, { log }); - } else { - // 默认当作 userId 处理 - log?.info?.(`[DingTalk][outbound.sendText] 默认发送给用户: userId="${targetStr}"`); - result = await sendToUser(config, targetStr, text, { log }); - } - - if (result.ok) { - return { channel: 'dingtalk-connector', messageId: result.processQueryKey || 'unknown' }; - } - throw new Error(result.error || 'Failed to send message'); - }, - /** - * 主动发送媒体消息(图片) - * @param ctx.to 目标格式:user: 或 group: - * @param ctx.text 消息文本/标题 - * @param ctx.mediaUrl 媒体 URL(钉钉仅支持图片 URL) - * @param ctx.accountId 账号 ID - */ - sendMedia: async (ctx: any) => { - const { cfg, to, text, mediaUrl, accountId, log } = ctx; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - const config = account?.config; - - if (!config?.clientId || !config?.clientSecret) { - throw new Error('DingTalk not configured'); - } - - if (!to) { - throw new Error('Target is required. Format: user: or group:'); - } - - // 解析目标 - const targetStr = String(to); - let result: SendResult; - - // 如果有媒体 URL,发送图片消息 - if (mediaUrl) { - if (targetStr.startsWith('user:')) { - const userId = targetStr.slice(5); - result = await sendToUser(config, userId, mediaUrl, { msgType: 'image', log }); - } else if (targetStr.startsWith('group:')) { - const openConversationId = targetStr.slice(6); - result = await sendToGroup(config, openConversationId, mediaUrl, { msgType: 'image', log }); - } else { - result = await sendToUser(config, targetStr, mediaUrl, { msgType: 'image', log }); - } - } else { - // 无媒体,发送文本 - if (targetStr.startsWith('user:')) { - const userId = targetStr.slice(5); - result = await sendToUser(config, userId, text || '', { log }); - } else if (targetStr.startsWith('group:')) { - const openConversationId = targetStr.slice(6); - result = await sendToGroup(config, openConversationId, text || '', { log }); - } else { - result = await sendToUser(config, targetStr, text || '', { log }); - } - } - - if (result.ok) { - return { channel: 'dingtalk-connector', messageId: result.processQueryKey || 'unknown' }; - } - throw new Error(result.error || 'Failed to send media'); - }, - }, - gateway: { - startAccount: async (ctx: any) => { - const { account, cfg, abortSignal } = ctx; - const config = account.config; - - if (!config.clientId || !config.clientSecret) { - throw new Error('DingTalk clientId and clientSecret are required'); - } - - ctx.log?.info(`[${account.accountId}] 启动钉钉 Stream 客户端...`); - - // 启用 DWClient 内置的 autoReconnect 和 keepAlive - // - autoReconnect: 连接断开时自动重连 - // - keepAlive: 启用心跳机制,防止服务端因长时间无活动而断开连接 - const client = new DWClient({ - clientId: config.clientId, - clientSecret: config.clientSecret, - debug: config.debug || false, - autoReconnect: true, - keepAlive: true, - } as any); - - client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => { - const messageId = res.headers?.messageId; - ctx.log?.info?.(`[DingTalk] 收到 Stream 回调, messageId=${messageId}, headers=${JSON.stringify(res.headers)}`); - - // 【关键修复】立即确认回调,避免钉钉服务器因超时而重发 - // 钉钉 Stream 模式要求及时响应,否则约60秒后会重发消息 - if (messageId) { - client.socketCallBackResponse(messageId, { success: true }); - ctx.log?.info?.(`[DingTalk] 已立即确认回调: messageId=${messageId}`); - } - - // 【消息去重】检查是否已处理过该消息 - if (messageId && isMessageProcessed(messageId)) { - ctx.log?.warn?.(`[DingTalk] 检测到重复消息,跳过处理: messageId=${messageId}`); - return; - } - - // 标记消息为已处理 - if (messageId) { - markMessageProcessed(messageId); - } - - // 异步处理消息(不阻塞回调确认) - try { - ctx.log?.info?.(`[DingTalk] 原始 data: ${typeof res.data === 'string' ? res.data.slice(0, 500) : JSON.stringify(res.data).slice(0, 500)}`); - const data = JSON.parse(res.data); - - await handleDingTalkMessage({ - cfg, - accountId: account.accountId, - data, - sessionWebhook: data.sessionWebhook, - log: ctx.log, - dingtalkConfig: config, - }); - } catch (error: any) { - ctx.log?.error?.(`[DingTalk] 处理消息异常: ${error.message}`); - // 注意:即使处理失败,也不需要再次响应(已经提前确认了) - } - }); - - await client.connect(); - ctx.log?.info(`[${account.accountId}] 钉钉 Stream 客户端已连接`); - - const rt = getRuntime(); - rt.channel.activity.record('dingtalk-connector', account.accountId, 'start'); - - let stopped = false; - - // 统一的停止逻辑 - const doStop = (reason: string) => { - if (stopped) return; - stopped = true; - ctx.log?.info(`[${account.accountId}] 停止钉钉 Stream 客户端 (${reason})...`); - try { - // 【关键】调用 disconnect() 正确关闭 WebSocket 连接 - client.disconnect(); - } catch (err: any) { - ctx.log?.warn?.(`[${account.accountId}] 断开连接时出错: ${err.message}`); - } - rt.channel.activity.record('dingtalk-connector', account.accountId, 'stop'); - }; - - // 【关键修复】返回一个 Promise 并保持 pending 状态直到 abortSignal 触发 - // 这样框架不会认为账号已退出,避免触发 auto-restart - // 参考:OpenClaw changelog - "keep startAccount pending until abort to prevent restart-loop storms" - return new Promise((resolve) => { - if (abortSignal) { - abortSignal.addEventListener('abort', () => { - doStop('abortSignal'); - resolve({ - stop: () => doStop('manual'), - isHealthy: () => !stopped, - }); - }); - } - }); - }, - }, - status: { - defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, running: false, lastStartAt: null, lastStopAt: null, lastError: null }, - probe: async ({ cfg }: any) => { - if (!isConfigured(cfg)) return { ok: false, error: 'Not configured' }; - try { - const config = getConfig(cfg); - await getAccessToken(config); - return { ok: true, details: { clientId: config.clientId } }; - } catch (error: any) { - return { ok: false, error: error.message }; - } - }, - buildChannelSummary: ({ snapshot }: any) => ({ - configured: snapshot?.configured ?? false, - running: snapshot?.running ?? false, - lastStartAt: snapshot?.lastStartAt ?? null, - lastStopAt: snapshot?.lastStopAt ?? null, - lastError: snapshot?.lastError ?? null, - }), - }, -}; - -// ============ 插件注册 ============ - -const plugin = { - id: 'dingtalk-connector', - name: 'DingTalk Channel', - description: 'DingTalk (钉钉) messaging channel via Stream mode with AI Card streaming', - configSchema: { - type: 'object', - additionalProperties: true, - properties: { enabled: { type: 'boolean', default: true } }, - }, - register(api: ClawdbotPluginApi) { - runtime = api.runtime; - api.registerChannel({ plugin: dingtalkPlugin }); - - // ===== Gateway Methods ===== - - api.registerGatewayMethod('dingtalk-connector.status', async ({ respond, cfg }: any) => { - const result = await dingtalkPlugin.status.probe({ cfg }); - respond(true, result); - }); - - api.registerGatewayMethod('dingtalk-connector.probe', async ({ respond, cfg }: any) => { - const result = await dingtalkPlugin.status.probe({ cfg }); - respond(result.ok, result); - }); - - /** - * 主动发送单聊消息 - * 参数: - * - userId / userIds: 目标用户 ID(支持单个或数组) - * - content: 消息内容 - * - msgType?: 'text' | 'markdown' | 'link' | 'actionCard' | 'image'(降级时使用,默认 text) - * - title?: markdown 消息标题 - * - useAICard?: 是否使用 AI Card(默认 true) - * - fallbackToNormal?: AI Card 失败时是否降级到普通消息(默认 true) - * - accountId?: 使用的账号 ID(可选,不传则使用默认配置) - */ - api.registerGatewayMethod('dingtalk-connector.sendToUser', async ({ respond, cfg, params, log }: any) => { - const { userId, userIds, content, msgType, title, useAICard, fallbackToNormal, accountId } = params || {}; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - - const targetUserIds = userIds || (userId ? [userId] : []); - if (targetUserIds.length === 0) { - return respond(false, { error: 'userId or userIds is required' }); - } - - if (!content) { - return respond(false, { error: 'content is required' }); - } - - const result = await sendToUser(account.config, targetUserIds, content, { - msgType, - title, - log, - useAICard: useAICard !== false, // 默认 true - fallbackToNormal: fallbackToNormal !== false, // 默认 true - }); - respond(result.ok, result); - }); - - /** - * 主动发送群聊消息 - * 参数: - * - openConversationId: 群会话 ID - * - content: 消息内容 - * - msgType?: 'text' | 'markdown' | 'link' | 'actionCard' | 'image'(降级时使用,默认 text) - * - title?: markdown 消息标题 - * - useAICard?: 是否使用 AI Card(默认 true) - * - fallbackToNormal?: AI Card 失败时是否降级到普通消息(默认 true) - * - accountId?: 使用的账号 ID(可选,不传则使用默认配置) - */ - api.registerGatewayMethod('dingtalk-connector.sendToGroup', async ({ respond, cfg, params, log }: any) => { - const { openConversationId, content, msgType, title, useAICard, fallbackToNormal, accountId } = params || {}; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - - if (!openConversationId) { - return respond(false, { error: 'openConversationId is required' }); - } - - if (!content) { - return respond(false, { error: 'content is required' }); - } - - const result = await sendToGroup(account.config, openConversationId, content, { - msgType, - title, - log, - useAICard: useAICard !== false, // 默认 true - fallbackToNormal: fallbackToNormal !== false, - }); - respond(result.ok, result); - }); - - /** - * 智能发送消息(自动检测目标类型和消息格式) - * 参数: - * - target: 目标(user: 或 group:) - * - content: 消息内容 - * - msgType?: 消息类型(降级时使用,可选,不指定则自动检测) - * - title?: 标题(用于 markdown) - * - useAICard?: 是否使用 AI Card(默认 true) - * - fallbackToNormal?: AI Card 失败时是否降级到普通消息(默认 true) - * - accountId?: 账号 ID - */ - api.registerGatewayMethod('dingtalk-connector.send', async ({ respond, cfg, params, log }: any) => { - const { target, content, message, msgType, title, useAICard, fallbackToNormal, accountId } = params || {}; - const actualContent = content || message; // 兼容 message 字段 - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - log?.info?.(`[DingTalk][Send] 收到请求: params=${JSON.stringify(params)}`); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - - if (!target) { - return respond(false, { error: 'target is required (format: user: or group:)' }); - } - - if (!actualContent) { - return respond(false, { error: 'content is required' }); - } - - const targetStr = String(target); - let sendTarget: { userId?: string; openConversationId?: string }; - - if (targetStr.startsWith('user:')) { - sendTarget = { userId: targetStr.slice(5) }; - } else if (targetStr.startsWith('group:')) { - sendTarget = { openConversationId: targetStr.slice(6) }; - } else { - // 默认当作 userId - sendTarget = { userId: targetStr }; - } - - log?.info?.(`[DingTalk][Send] 解析后目标: sendTarget=${JSON.stringify(sendTarget)}`); - - const result = await sendProactive(account.config, sendTarget, actualContent, { - msgType, - title, - log, - useAICard: useAICard !== false, // 默认 true - fallbackToNormal: fallbackToNormal !== false, - }); - respond(result.ok, result); - }); - - // ===== 文档 API Methods ===== - - /** - * 读取钉钉知识库文档节点信息 - * 参数: - * - docId: 知识库节点 ID - * - operatorId: 操作者 unionId 或 staffId(会自动转换为 unionId) - * - accountId?: 账号 ID - */ - api.registerGatewayMethod('dingtalk-connector.docs.read', async ({ respond, cfg, params, log }: any) => { - const { docId, operatorId: rawOperatorId, accountId } = params || {}; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - if (!docId) { - return respond(false, { error: 'docId is required' }); - } - if (!rawOperatorId) { - return respond(false, { error: 'operatorId (unionId or staffId) is required' }); - } - - // 如果 operatorId 不像 unionId(通常以字母数字开头且较长),尝试将 staffId 转为 unionId - let operatorId = rawOperatorId; - if (!rawOperatorId.includes('$')) { - // 可能已经是 unionId,直接使用;否则尝试转换 - const resolved = await getUnionId(rawOperatorId, account.config, log); - if (resolved) operatorId = resolved; - } - - const client = new DingtalkDocsClient(account.config, log); - const content = await client.readDoc(docId, operatorId); - - if (content !== null) { - respond(true, { content }); - } else { - respond(false, { error: 'Failed to read document node' }); - } - }); - - /** - * 创建钉钉文档 - * 参数: - * - spaceId: 空间 ID - * - title: 文档标题 - * - content?: 初始内容 - * - accountId?: 账号 ID - */ - api.registerGatewayMethod('dingtalk-connector.docs.create', async ({ respond, cfg, params, log }: any) => { - const { spaceId, title, content, accountId } = params || {}; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - if (!spaceId || !title) { - return respond(false, { error: 'spaceId and title are required' }); - } - - const client = new DingtalkDocsClient(account.config, log); - const doc = await client.createDoc(spaceId, title, content); - - if (doc) { - respond(true, doc); - } else { - respond(false, { error: 'Failed to create document' }); - } - }); - - /** - * 向钉钉文档追加内容 - * 参数: - * - docId: 文档 ID - * - content: 要追加的内容 - * - accountId?: 账号 ID - */ - api.registerGatewayMethod('dingtalk-connector.docs.append', async ({ respond, cfg, params, log }: any) => { - const { docId, content, accountId } = params || {}; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - if (!docId || !content) { - return respond(false, { error: 'docId and content are required' }); - } - - const client = new DingtalkDocsClient(account.config, log); - const ok = await client.appendToDoc(docId, content); - respond(ok, ok ? { success: true } : { error: 'Failed to append to document' }); - }); - - /** - * 搜索钉钉文档 - * 参数: - * - keyword: 搜索关键词 - * - spaceId?: 空间 ID(可选) - * - accountId?: 账号 ID - */ - api.registerGatewayMethod('dingtalk-connector.docs.search', async ({ respond, cfg, params, log }: any) => { - const { keyword, spaceId, accountId } = params || {}; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - if (!keyword) { - return respond(false, { error: 'keyword is required' }); - } - - const client = new DingtalkDocsClient(account.config, log); - const docs = await client.searchDocs(keyword, spaceId); - respond(true, { docs }); - }); - - /** - * 列出空间下的文档 - * 参数: - * - spaceId: 空间 ID - * - parentId?: 父目录 ID(可选) - * - accountId?: 账号 ID - */ - api.registerGatewayMethod('dingtalk-connector.docs.list', async ({ respond, cfg, params, log }: any) => { - const { spaceId, parentId, accountId } = params || {}; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - if (!spaceId) { - return respond(false, { error: 'spaceId is required' }); - } - - const client = new DingtalkDocsClient(account.config, log); - const docs = await client.listDocs(spaceId, parentId); - respond(true, { docs }); - }); - - api.logger?.info('[DingTalk] 插件已注册(支持主动发送 AI Card 消息、文档读写)'); - }, -}; - -export default plugin; -export { - dingtalkPlugin, - // 回复消息(需要 sessionWebhook) - sendMessage, - sendTextMessage, - sendMarkdownMessage, - // 主动发送消息(无需 sessionWebhook) - sendToUser, - sendToGroup, - sendProactive, - // 钉钉文档客户端 - DingtalkDocsClient, -}; diff --git a/.flocks/plugins/channels/dingtalk/dingtalk.py b/.flocks/plugins/channels/dingtalk/dingtalk.py deleted file mode 100644 index ae3ff0417..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk.py +++ /dev/null @@ -1,368 +0,0 @@ -""" -DingTalk ChannelPlugin for flocks. - -Launches runner.ts (via npm) as a subprocess. runner.ts constructs a minimal -OpenClaw runtime shim that drives plugin.ts's DWClient WebSocket connection -to DingTalk. All AI inference requests are served through flocks's -POST /v1/chat/completions endpoint. - -Location: - .flocks/plugins/channels/dingtalk/dingtalk.py - -Directory layout: - dingtalk/ - ├── dingtalk.py ← this file (auto-loaded by flocks) - ├── runner.ts ← Node.js bridge layer (no modification needed) - └── dingtalk-openclaw-connector/ - └── plugin.ts ← original connector (no modification needed) - -flocks.json configuration example: - { - "channels": { - "dingtalk": { - "enabled": true, - "clientId": "dingXXXXXX", - "clientSecret": "your_secret", - "defaultAgent": "rex" - // Active outbound (channel_message tool, agent-initiated push) - // reuses the same credential pair — robotCode defaults to clientId - // for the standard "enterprise internal app robot" setup. Override - // by adding "robotCode": "..." only if your app hosts multiple - // robots and the console issues a distinct code. - } - } - } - -Optional extra fields (passed through to plugin.ts): - gatewayToken Bearer auth token (usually not needed; flocks has no local auth) - debug true/false, enables plugin.ts debug logging - separateSessionByConversation true (default) - groupSessionScope "group" (default) / "group_sender" - sharedMemoryAcrossConversations false (default) - dmPolicy "open" (default) / "allowlist" - allowFrom list of allowed senderStaffId values -""" - -from __future__ import annotations - -import asyncio -import os -import subprocess -import sys -from pathlib import Path -from typing import Any, Awaitable, Callable, Optional - -from flocks.channel.base import ( - ChannelCapabilities, - ChannelMeta, - ChannelPlugin, - ChatType, - DeliveryResult, - InboundMessage, - OutboundContext, -) -from flocks.utils.log import Log - -log = Log.create(service="channel.dingtalk") - -# Directory containing runner.ts (same level as this file) -_PLUGIN_DIR = Path(__file__).parent -_RUNNER_TS = _PLUGIN_DIR / "runner.ts" -_CONNECTOR_DIR = _PLUGIN_DIR / "dingtalk-openclaw-connector" -_CONNECTOR_PACKAGE = _CONNECTOR_DIR / "package.json" - - -def _find_npm() -> str: - """Return the npm executable path, raising if not found.""" - if npm := os.environ.get("NPM_PATH"): - return npm - - import shutil - - for candidate in ("npm", "npm.cmd"): - if npm := shutil.which(candidate): - return npm - - raise RuntimeError( - "npm not found. Please install Node.js (which includes npm) or set the NPM_PATH environment variable." - ) - - -class DingTalkChannel(ChannelPlugin): - """DingTalk channel — bridges to plugin.ts via a runner.ts subprocess.""" - - def __init__(self) -> None: - super().__init__() - self._proc: Optional[subprocess.Popen] = None - self._monitor_task: Optional[asyncio.Task] = None - - # ── Metadata ────────────────────────────────────────────────────────────── - - def meta(self) -> ChannelMeta: - return ChannelMeta( - id="dingtalk", - label="DingTalk", - aliases=["dingding", "dingtalk-connector"], - order=30, - ) - - def capabilities(self) -> ChannelCapabilities: - return ChannelCapabilities( - chat_types=[ChatType.DIRECT, ChatType.GROUP], - media=True, - threads=False, - reactions=False, - edit=False, - rich_text=True, - ) - - def validate_config(self, config: dict) -> Optional[str]: - for key in ("clientId", "clientSecret"): - if not config.get(key): - return f"Missing required config field: {key}" - if not _RUNNER_TS.exists(): - return f"runner.ts not found: {_RUNNER_TS}" - if not _CONNECTOR_PACKAGE.exists(): - return f"package.json not found: {_CONNECTOR_PACKAGE}" - node_modules = _CONNECTOR_DIR / "node_modules" - if not node_modules.is_dir(): - return ( - f"node_modules not found in {_CONNECTOR_DIR}. " - "Run `npm install` (or `bun install`) inside that directory first." - ) - return None - - # ── Lifecycle ───────────────────────────────────────────────────────────── - - async def start( - self, - config: dict, - on_message: Callable[[InboundMessage], Awaitable[None]], - abort_event: Optional[asyncio.Event] = None, - ) -> None: - """Start the runner.ts subprocess and monitor it until abort_event fires. - - Design note: DingTalk inbound messages are handled entirely inside the - runner.ts ↔ plugin.ts layer, which calls the flocks Session API directly. - The `on_message` / InboundDispatcher path (used by Feishu, WeCom, Telegram) - is intentionally NOT used here; this means dedup, debounce and - channel.inbound hooks are the responsibility of plugin.ts itself. - - Channel binding is *not* skipped: runner.ts calls - ``POST /api/channel/dingtalk/bind`` after each session creation, so - ``channel_bindings`` stays in sync with the rest of flocks and the - ``channel_message`` tool can route outbound replies back through - :meth:`send_text`. - """ - self._config = config - self._on_message = on_message - - npm = _find_npm() - flocks_port = self._get_flocks_port() - - env = { - **os.environ, - # Accept appKey/appSecret as aliases so a single credential pair - # works for both Stream inbound (Node.js) and OAPI outbound (Python). - "DINGTALK_CLIENT_ID": config.get("clientId") or config.get("appKey", ""), - "DINGTALK_CLIENT_SECRET": config.get("clientSecret") or config.get("appSecret", ""), - "FLOCKS_PORT": str(flocks_port), - "FLOCKS_AGENT": config.get("defaultAgent", ""), - "FLOCKS_GATEWAY_TOKEN": config.get("gatewayToken", ""), - "DINGTALK_DEBUG": "true" if config.get("debug") else "false", - "DINGTALK_ACCOUNT_ID": config.get("_account_id", "__default__"), - # Optional policy / behaviour fields forwarded to plugin.ts - "DINGTALK_DM_POLICY": str(config.get("dmPolicy", "")), - "DINGTALK_ALLOW_FROM": ",".join(config.get("allowFrom") or []), - "DINGTALK_SEPARATE_SESSION": "true" if config.get("separateSessionByConversation", True) else "false", - "DINGTALK_GROUP_SESSION_SCOPE": str(config.get("groupSessionScope", "")), - "DINGTALK_SHARED_MEMORY": "true" if config.get("sharedMemoryAcrossConversations") else "false", - } - - log.info("dingtalk.start", { - "runner": str(_RUNNER_TS), - "flocks_port": flocks_port, - "client_id": config.get("clientId") or config.get("appKey", ""), - }) - - self._start_process(npm, env) - self.mark_connected() - - # Monitor subprocess until abort_event is set - self._monitor_task = asyncio.create_task( - self._monitor(abort_event) - ) - await self._monitor_task - - async def stop(self) -> None: - if self._monitor_task and not self._monitor_task.done(): - self._monitor_task.cancel() - await self._kill_process_async() - self.mark_disconnected() - - # ── Outbound messages ───────────────────────────────────────────────────── - # Passive replies (agent → user, in response to an inbound message) are - # handled inside runner.ts ↔ plugin.ts via sessionWebhook and never reach - # send_text. Active push (e.g. the channel_message tool, or an agent - # proactively notifying a DingTalk user) goes through the shared OAPI - # send library at flocks.channel.builtin.dingtalk.send_message_app, which - # mirrors how Feishu / WeCom expose their outbound surfaces. - - async def send_text(self, ctx: OutboundContext) -> DeliveryResult: - """Push a text/markdown message to DingTalk via the OAPI app robot. - - Reuses the inbound credentials (``clientId``/``clientSecret``, also - accepted as ``appKey``/``appSecret``). ``robotCode`` defaults to - ``clientId`` — only set it explicitly when one app hosts multiple - robots. Targets must be ``user:`` or - ``chat:``. - - The channel config is re-read from :class:`flocks.config.config.Config` - on every call rather than from ``self._config``: PluginLoader scans - project-local plugins more than once on startup (default scan's - ``project_subdir`` step + an explicit project scan), and each pass - registers a *fresh* ``DingTalkChannel()`` instance into the registry, - overwriting the one ``GatewayManager`` had run ``start()`` on. The - outbound path then receives an instance whose ``self._config`` is - ``None``. Reading the config live also means UI edits take effect - without restarting the runner. - """ - try: - from flocks.channel.builtin.dingtalk import ( - DingTalkApiError, - send_message_app, - strip_target_prefix, - ) - except ImportError as exc: - return DeliveryResult( - channel_id="dingtalk", message_id="", - success=False, - error=f"DingTalk send library unavailable: {exc}", - ) - - send_config = await self._resolve_outbound_config() - if not ctx.to or not strip_target_prefix(ctx.to): - return DeliveryResult( - channel_id="dingtalk", message_id="", - success=False, - error="DingTalk active outbound requires 'to' (user: or chat:)", - ) - - try: - result = await send_message_app( - config=send_config, - to=ctx.to, - text=ctx.text, - account_id=ctx.account_id, - ) - self.record_message() - return DeliveryResult( - channel_id="dingtalk", - message_id=str(result.get("message_id", "")), - chat_id=result.get("chat_id"), - ) - except Exception as e: - retryable = getattr(e, "retryable", False) - if not retryable and not isinstance(e, DingTalkApiError): - msg = str(e).lower() - retryable = "rate limit" in msg or "timeout" in msg - log.warning("dingtalk.send_text.failed", { - "to": ctx.to, "error": str(e), "retryable": retryable, - }) - return DeliveryResult( - channel_id="dingtalk", message_id="", - success=False, error=str(e), retryable=retryable, - ) - - async def _resolve_outbound_config(self) -> dict: - """Live-read the dingtalk channel config block from flocks.json. - - Falls back to ``self._config`` (set by ``start()``) when the global - config can't be loaded — this keeps unit tests that bypass the global - config working. - """ - try: - from flocks.config.config import Config - cfg = await Config.get() - channels = cfg.channels or {} - # Only treat the live config as authoritative when ``dingtalk`` is - # actually declared — ``get_channel_config`` synthesises a default - # ``ChannelConfig()`` for missing channels, whose model_dump still - # contains non-credential fields and would mask self._config. - if "dingtalk" in channels: - # by_alias=True keeps clientId/clientSecret as their JSON - # names (rather than pydantic's snake_case attributes) so - # _merged_app_key/_merged_app_secret pick them up unchanged. - return channels["dingtalk"].model_dump( - by_alias=True, exclude_none=True, - ) - except Exception as e: - log.warning("dingtalk.send_text.config_fallback", {"error": str(e)}) - return dict(self._config or {}) - - # ── Internal methods ────────────────────────────────────────────────────── - - def _get_flocks_port(self) -> int: - """Get the flocks HTTP port from the environment variable or fall back to the default.""" - return int(os.environ.get("FLOCKS_PORT", "8000")) - - def _start_process(self, npm: str, env: dict) -> None: - """Start the runner.ts subprocess.""" - self._proc = subprocess.Popen( - [npm, "run", "start:runner"], - cwd=str(_CONNECTOR_DIR), - env=env, - stdout=sys.stdout, - stderr=sys.stderr, - ) - log.info("dingtalk.process.started", {"pid": self._proc.pid}) - - async def _kill_process_async(self) -> None: - """Terminate the subprocess without blocking the asyncio event loop.""" - proc = self._proc - self._proc = None - if proc is None or proc.poll() is not None: - return - pid = proc.pid - log.info("dingtalk.process.terminating", {"pid": pid}) - proc.terminate() - try: - await asyncio.wait_for(asyncio.to_thread(proc.wait), timeout=5.0) - except asyncio.TimeoutError: - proc.kill() - await asyncio.to_thread(proc.wait) - log.info("dingtalk.process.stopped", {"pid": pid}) - - async def _monitor(self, abort_event: Optional[asyncio.Event]) -> None: - """Monitor the subprocess; raise RuntimeError on non-zero exit; stop when abort_event fires.""" - exit_code: Optional[int] = None - try: - while True: - if abort_event and abort_event.is_set(): - log.info("dingtalk.monitor.abort") - break - - # Non-blocking check whether the process has exited - if self._proc and self._proc.poll() is not None: - exit_code = self._proc.returncode - if exit_code != 0: - log.error("dingtalk.process.exited_unexpectedly", {"returncode": exit_code}) - else: - log.info("dingtalk.process.exited_normally", {"returncode": exit_code}) - break - - await asyncio.sleep(2) - except asyncio.CancelledError: - pass - finally: - # Non-blocking cleanup: must not block the event loop while waiting for - # the Node.js process to exit (can take up to 5s with SIGTERM). - await self._kill_process_async() - - # Raise after cleanup so the gateway reconnect loop applies exponential backoff. - if exit_code is not None and exit_code != 0: - raise RuntimeError(f"runner.ts exited unexpectedly, exit code={exit_code}") - - -# Discovered by flocks PluginLoader via this variable -CHANNELS = [DingTalkChannel()] diff --git a/.flocks/plugins/channels/dingtalk/package.json b/.flocks/plugins/channels/dingtalk/package.json deleted file mode 100644 index 5ffd9800b..000000000 --- a/.flocks/plugins/channels/dingtalk/package.json +++ /dev/null @@ -1 +0,0 @@ -{ "type": "module" } diff --git a/.flocks/plugins/channels/dingtalk/runner.ts b/.flocks/plugins/channels/dingtalk/runner.ts deleted file mode 100644 index dd9f2d070..000000000 --- a/.flocks/plugins/channels/dingtalk/runner.ts +++ /dev/null @@ -1,474 +0,0 @@ -/** - * runner.ts — flocks DingTalk bridge - * - * Constructs a minimal OpenClaw PluginRuntime/ClawdbotPluginApi shim so that - * plugin.ts can run inside the flocks environment without any modifications. - * - * Key substitution: - * plugin.ts internally calls streamFromGateway(), which posts to - * POST http://127.0.0.1:{port}/v1/chat/completions (SSE) - * We point that port at a lightweight HTTP proxy embedded in this file. - * The proxy translates the OpenAI format into real flocks API calls: - * POST /api/session → create or reuse a session - * POST /api/session/{id}/message → trigger inference - * GET /api/event → SSE, filter message.part.updated.delta - * Results are streamed back to plugin.ts as OpenAI SSE chunks — zero - * modifications to plugin.ts required. - * - * Startup (invoked by dingtalk.py via subprocess): - * DINGTALK_CLIENT_ID=xxx DINGTALK_CLIENT_SECRET=xxx FLOCKS_PORT=8000 bun run runner.ts - */ - -import plugin from './dingtalk-openclaw-connector/plugin.ts'; -import { createServer, type IncomingMessage, type ServerResponse } from 'http'; - -// ── Environment variables ──────────────────────────────────────────────────── -const CLIENT_ID = process.env.DINGTALK_CLIENT_ID || ''; -const CLIENT_SECRET = process.env.DINGTALK_CLIENT_SECRET || ''; -const FLOCKS_PORT = parseInt(process.env.FLOCKS_PORT || '8000', 10); -const FLOCKS_AGENT = process.env.FLOCKS_AGENT || ''; -const GATEWAY_TOKEN = process.env.FLOCKS_GATEWAY_TOKEN || ''; -const DEBUG = process.env.DINGTALK_DEBUG === 'true'; -const ACCOUNT_ID = process.env.DINGTALK_ACCOUNT_ID || '__default__'; - -// Optional policy / behaviour fields forwarded from flocks.json → plugin.ts -const DM_POLICY = process.env.DINGTALK_DM_POLICY || ''; -const ALLOW_FROM_RAW = process.env.DINGTALK_ALLOW_FROM || ''; -const ALLOW_FROM = ALLOW_FROM_RAW ? ALLOW_FROM_RAW.split(',').map(s => s.trim()).filter(Boolean) : undefined; -const SEPARATE_SESSION = process.env.DINGTALK_SEPARATE_SESSION !== 'false'; // default true -const GROUP_SESSION_SCOPE = process.env.DINGTALK_GROUP_SESSION_SCOPE || ''; -const SHARED_MEMORY = process.env.DINGTALK_SHARED_MEMORY === 'true'; - -// Proxy listens on a random port; plugin.ts's streamFromGateway calls land here -const PROXY_HOST = '127.0.0.1'; -let PROXY_PORT = 0; // resolved after startup - -if (!CLIENT_ID || !CLIENT_SECRET) { - console.error('[runner] Missing environment variables DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET'); - process.exit(1); -} - -const FLOCKS_BASE = `http://127.0.0.1:${FLOCKS_PORT}`; - -// ── Session map: session_key → flocks session_id ─────────────────────────── -const sessionMap = new Map(); - -/** - * Parse a sessionKey (possibly a JSON string) into a human-readable session title. - * Format is consistent with Feishu/WeCom: - * DM → [Dingtalk] DM — {senderName} - * Group → [Dingtalk] {chatId} - */ -function buildSessionTitle(sessionKey: string): string { - try { - const info = JSON.parse(sessionKey); - const chatType: string = info.chatType || ''; - const senderName: string = info.senderName || info.peerId || sessionKey; - const chatId: string = info.peerId || info.chatId || sessionKey; - if (chatType === 'direct') { - return `[Dingtalk] DM — ${senderName}`; - } - return `[Dingtalk] ${chatId}`; - } catch { - // sessionKey is not JSON, use it as-is - return `[Dingtalk] ${sessionKey}`; - } -} - -/** - * Register the (channel, conversation) → session mapping in flocks's - * channel_bindings table so that the channel_message tool / - * POST /api/channel/session-send can route outbound replies back to this - * DingTalk conversation. - * - * This mirrors what InboundDispatcher.binding_service.resolve_or_create - * does for Feishu / WeCom / Telegram — DingTalk creates its session - * out-of-process, so we have to register the binding explicitly. - * - * IMPORTANT — chat_id resolution for groups: - * plugin.ts builds `peerId` differently depending on groupSessionScope: - * - `group` (default): peerId = conversationId (routable) - * - `group_sender`: peerId = `${conversationId}:${senderId}` - * A SESSION-ISOLATION composite, NOT a send target — OAPI expects the - * bare `openConversationId`. - * So for groups we must take `info.conversationId` and never fall back to - * `peerId`. `peerId` continues to drive session isolation on the - * sessionKey side; the binding row only stores the outbound-routable id. - * - * For direct chats peerId == senderId == staffId, which is the correct - * user target for `/v1.0/robot/oToMessages/batchSend`. - * - * Edge case — `separateSessionByConversation=false` + group: - * The connector omits `conversationId` from the sessionKey entirely and - * uses `peerId = senderId`, so there is no way to recover the - * openConversationId here. We skip binding with a warn rather than write - * a record that would resolve to the wrong target. - * - * Best-effort: a failure here only means the channel_message tool will 404 - * for this session, the inbound reply path keeps working. - */ -async function registerChannelBinding(sessionKey: string, sessionId: string): Promise { - let chatType: 'direct' | 'group' = 'direct'; - let chatId = ''; - - try { - const info = JSON.parse(sessionKey); - chatType = info.chatType === 'group' ? 'group' : 'direct'; - - if (chatType === 'group') { - chatId = info.conversationId || ''; - } else { - chatId = info.peerId || info.senderId || ''; - } - } catch { - // sessionKey is not JSON (legacy plain string) — treat as an opaque - // direct id. - chatId = sessionKey; - } - - if (!chatId) { - console.warn( - `[runner] bind skipped: no routable chat_id for sessionKey=${sessionKey} ` + - `(typical cause: separateSessionByConversation=false + group, where ` + - `the session cannot be mapped back to an openConversationId)` - ); - return; - } - - // The flocks-side channel_id is "dingtalk" (see ChannelMeta.id). Other - // values like "dingtalk-connector" are aliases declared on ChannelMeta and - // are accepted by the registry but the canonical binding row uses the id. - const url = `${FLOCKS_BASE}/api/channel/dingtalk/bind`; - const body = { - session_id: sessionId, - chat_id: chatId, - chat_type: chatType, - account_id: ACCOUNT_ID === '__default__' ? 'default' : ACCOUNT_ID, - }; - - try { - const r = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!r.ok) { - console.warn(`[runner] bind failed: ${r.status} ${await r.text()}`); - } else if (DEBUG) { - console.log(`[runner] bind ok: chat_id=${chatId} chat_type=${chatType} session=${sessionId}`); - } - } catch (e: any) { - console.warn(`[runner] bind error: ${e?.message || e}`); - } -} - -async function getOrCreateSession(sessionKey: string, agentName: string): Promise { - const existing = sessionMap.get(sessionKey); - if (existing) { - // Verify the session still exists - try { - const r = await fetch(`${FLOCKS_BASE}/api/session/${existing}`); - if (r.ok) return existing; - } catch {} - sessionMap.delete(sessionKey); - } - - const body: any = { title: buildSessionTitle(sessionKey) }; - if (agentName) body.agent = agentName; - - const r = await fetch(`${FLOCKS_BASE}/api/session`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!r.ok) throw new Error(`Failed to create session: ${r.status} ${await r.text()}`); - - const data: any = await r.json(); - const sessionId: string = data.id; - sessionMap.set(sessionKey, sessionId); - console.log(`[runner] session created: key=${sessionKey} id=${sessionId}`); - - // Register the channel binding so outbound tools can reach this session. - // Done after the in-memory cache write so a slow/failing bind never - // forces a duplicate session on the next message. - await registerChannelBinding(sessionKey, sessionId); - - return sessionId; -} - -// ── Convert flocks /api/event SSE to OpenAI delta SSE ──────────────────── -async function* flocksToOpenAIStream( - sessionId: string, - userText: string, - agentName: string, - systemPrompts: string[], -): AsyncGenerator { - // 1. Open event SSE connection first (before sending the message to avoid missing the first frame) - const eventUrl = `${FLOCKS_BASE}/api/event`; - const eventResp = await fetch(eventUrl, { - headers: { Accept: 'text/event-stream' }, - }); - if (!eventResp.ok || !eventResp.body) { - throw new Error(`Failed to connect to event SSE: ${eventResp.status}`); - } - - // 2. Send the user message to trigger inference - let fullText = userText; - if (systemPrompts.length > 0) { - const sys = systemPrompts.map(s => `\n${s}\n`).join('\n'); - fullText = `${sys}\n\n${userText}`; - } - - const msgBody: any = { - parts: [{ type: 'text', text: fullText }], - }; - if (agentName) msgBody.agent = agentName; - - const msgResp = await fetch(`${FLOCKS_BASE}/api/session/${sessionId}/message`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(msgBody), - }); - if (!msgResp.ok) { - throw new Error(`Failed to send message: ${msgResp.status} ${await msgResp.text()}`); - } - - // 3. Consume event SSE and extract message.part.updated deltas - const reader = eventResp.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let finished = false; - - while (!finished) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const raw = line.slice(6).trim(); - if (!raw || raw === '[DONE]') continue; - - let event: any; - try { event = JSON.parse(raw); } catch { continue; } - - const type = event.type; - const props = event.properties || {}; - - // text delta → OpenAI chunk - if (type === 'message.part.updated') { - // Only consume events belonging to this session to avoid mixing - // deltas from concurrent DingTalk / Web / TUI conversations. - const eventSessionId: string = props.part?.sessionID || ''; - if (eventSessionId && eventSessionId !== sessionId) continue; - - const delta: string = props.delta || ''; - const partType: string = props.part?.type || ''; - if (delta && partType === 'text') { - yield openAIChunk(delta); - } - } - - // Inference completion signal - if (type === 'message.updated') { - const eventSessionId: string = props.info?.sessionID || ''; - if (eventSessionId && eventSessionId !== sessionId) continue; - - const finish = props.info?.finish; - if (finish === 'stop' || finish === 'error') { - finished = true; - } - } - } - } - - reader.cancel().catch(() => {}); -} - -function openAIChunk(delta: string, finish?: string): string { - const chunk = { - id: 'chatcmpl-flocks', - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: 'flocks', - choices: [{ - index: 0, - delta: delta ? { content: delta } : {}, - finish_reason: finish ?? null, - }], - }; - return `data: ${JSON.stringify(chunk)}\n\n`; -} - -// ── Embedded HTTP proxy: translate /v1/chat/completions into flocks calls ── -function startProxy(): Promise { - return new Promise((resolve) => { - const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { - if (req.method !== 'POST' || req.url !== '/v1/chat/completions') { - res.writeHead(404); - res.end('Not found'); - return; - } - - // Read request body - const chunks: Buffer[] = []; - for await (const chunk of req) chunks.push(chunk as Buffer); - let body: any; - try { body = JSON.parse(Buffer.concat(chunks).toString()); } - catch { res.writeHead(400); res.end('Bad JSON'); return; } - - const messages: any[] = body.messages || []; - const sessionKey: string = body.user || 'default'; - const agentName: string = - (req.headers['x-openclaw-agent-id'] as string) || FLOCKS_AGENT || ''; - - const systemPrompts = messages - .filter(m => m.role === 'system' && m.content) - .map(m => m.content as string); - - let userText = ''; - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === 'user') { - userText = typeof messages[i].content === 'string' - ? messages[i].content - : String(messages[i].content); - break; - } - } - - if (DEBUG) { - console.log(`[proxy] session_key=${sessionKey} agent=${agentName} preview=${userText.slice(0, 60)}`); - } - - if (!userText) { - res.writeHead(200, { 'Content-Type': 'text/event-stream' }); - res.write(openAIChunk('', 'stop')); - res.write('data: [DONE]\n\n'); - res.end(); - return; - } - - // Record inbound activity as early as possible, before any Flocks API - // calls that might fail (session creation, inference, etc.). - fetch(`${FLOCKS_BASE}/api/channel/dingtalk/record-inbound`, { method: 'POST' }).catch(() => {}); - - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'X-Accel-Buffering': 'no', - }); - - try { - const sessionId = await getOrCreateSession(sessionKey, agentName); - for await (const chunk of flocksToOpenAIStream(sessionId, userText, agentName, systemPrompts)) { - res.write(chunk); - } - res.write(openAIChunk('', 'stop')); - res.write('data: [DONE]\n\n'); - } catch (err: any) { - console.error('[proxy] Request failed:', err.message); - res.write(`data: ${JSON.stringify({ error: { message: err.message } })}\n\n`); - res.write('data: [DONE]\n\n'); - } - res.end(); - }); - - server.listen(0, PROXY_HOST, () => { - const addr = server.address() as { port: number }; - PROXY_PORT = addr.port; - console.log(`[runner] proxy listening on ${PROXY_HOST}:${PROXY_PORT} → flocks :${FLOCKS_PORT}`); - resolve(PROXY_PORT); - }); - }); -} - -// ── Fake runtime shim ─────────────────────────────────────────────────────── -const fakeRuntime = { - gateway: { port: PROXY_PORT }, // updated with the actual port after startProxy() - channel: { - activity: { - record: (channelId: string, accountId: string, event: string) => { - if (DEBUG) console.log(`[runner][activity] ${channelId}/${accountId}: ${event}`); - }, - }, - }, -}; - -// ── Fake API shim ─────────────────────────────────────────────────────────── -const fakeApi: any = { - runtime: fakeRuntime, - logger: { - info: (msg: string) => console.log(`[plugin] ${msg}`), - warn: (msg: string) => console.warn(`[plugin] ${msg}`), - error: (msg: string) => console.error(`[plugin] ${msg}`), - debug: (msg: string) => { if (DEBUG) console.log(`[plugin:debug] ${msg}`); }, - }, - - registerChannel({ plugin: channelPlugin }: any) { - console.log(`[runner] registerChannel → starting startAccount (accountId=${ACCOUNT_ID})`); - - const abortController = new AbortController(); - const shutdown = () => { - console.log('[runner] shutdown signal received, aborting...'); - abortController.abort(); - }; - process.once('SIGTERM', shutdown); - process.once('SIGINT', shutdown); - - // cfg.gateway.port points to the local proxy - const cfg = { - channels: { - 'dingtalk-connector': { - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - gatewayToken: GATEWAY_TOKEN, - debug: DEBUG, - ...(FLOCKS_AGENT ? { defaultAgent: FLOCKS_AGENT } : {}), - // Optional policy / behaviour fields (only set when non-empty / non-default) - ...(DM_POLICY ? { dmPolicy: DM_POLICY } : {}), - ...(ALLOW_FROM ? { allowFrom: ALLOW_FROM } : {}), - ...(SEPARATE_SESSION ? {} : { separateSessionByConversation: false }), - ...(GROUP_SESSION_SCOPE ? { groupSessionScope: GROUP_SESSION_SCOPE } : {}), - ...(SHARED_MEMORY ? { sharedMemoryAcrossConversations: true } : {}), - }, - }, - gateway: { port: PROXY_PORT }, - }; - - channelPlugin.gateway.startAccount({ - account: { - accountId: ACCOUNT_ID, - config: cfg.channels['dingtalk-connector'], - }, - cfg, - abortSignal: abortController.signal, - log: { - info: (msg: string) => console.log(`[dingtalk] ${msg}`), - warn: (msg: string) => console.warn(`[dingtalk] ${msg}`), - error: (msg: string) => console.error(`[dingtalk] ${msg}`), - debug: (msg: string) => { if (DEBUG) console.log(`[dingtalk:debug] ${msg}`); }, - }, - }).catch((err: Error) => { - console.error('[runner] startAccount error:', err.message); - process.exit(1); - }); - }, - - registerGatewayMethod(name: string, _fn: any) { - if (DEBUG) console.log(`[runner] registerGatewayMethod: ${name} (noop)`); - }, -}; - -// ── Startup: launch proxy first, then register the plugin ─────────────────── -(async () => { - await startProxy(); - - // Sync the resolved port into fakeRuntime (cfg.gateway.port is set inline in registerChannel) - fakeRuntime.gateway.port = PROXY_PORT; - - console.log(`[runner] starting DingTalk connector → flocks :${FLOCKS_PORT}`); - plugin.register(fakeApi); -})(); diff --git a/flocks/channel/builtin/dingtalk/__init__.py b/flocks/channel/builtin/dingtalk/__init__.py index 11dc56647..bb9ba225b 100644 --- a/flocks/channel/builtin/dingtalk/__init__.py +++ b/flocks/channel/builtin/dingtalk/__init__.py @@ -1,16 +1,21 @@ """ -DingTalk outbound send library. +DingTalk channel — Stream Mode inbound + OAPI app-robot outbound. -This package intentionally does **not** ship a ``ChannelPlugin`` class — -the inbound side is owned by the project-local plugin -``.flocks/plugins/channels/dingtalk/dingtalk.py`` (Node.js connector). +The :class:`DingTalkChannel` plugin connects to DingTalk's Stream Mode +WebSocket via the official ``dingtalk-stream`` SDK and routes outbound +messages through the enterprise app-robot OAPI. It supersedes the +legacy Node.js connector that previously owned the ``dingtalk`` channel +id. -Other code (channel plugins, tools, hooks, …) can drive active outbound -messages by importing :func:`send_message_app` directly. Only the -enterprise app robot OAPI path is supported; custom group webhooks are -intentionally out of scope. +Public surface: + +* :class:`DingTalkChannel` — the registered plugin entry point. +* :func:`send_message_app` — low-level OAPI sender, reusable from tools + and hooks that need to push messages without going through the + channel plugin (e.g. ``channel_message`` tool). """ +from flocks.channel.builtin.dingtalk.channel import DingTalkChannel from flocks.channel.builtin.dingtalk.client import ( DingTalkApiError, close_http_client, @@ -25,6 +30,7 @@ __all__ = [ "DingTalkApiError", + "DingTalkChannel", "build_app_payload", "close_http_client", "send_message_app", diff --git a/flocks/channel/builtin/dingtalk/channel.py b/flocks/channel/builtin/dingtalk/channel.py new file mode 100644 index 000000000..75b4148dc --- /dev/null +++ b/flocks/channel/builtin/dingtalk/channel.py @@ -0,0 +1,354 @@ +""" +DingTalk ChannelPlugin — Stream Mode inbound + OAPI app-robot outbound. + +This plugin replaces the legacy Node.js connector +(``.flocks/plugins/channels/dingtalk/dingtalk.py``) with a pure-Python +implementation modelled on the Hermes Agent's ``DingTalkAdapter``. + +* Inbound: long-lived WebSocket via :mod:`dingtalk_stream` (>= 0.20). +* Outbound: enterprise app-robot OAPI via the existing + :func:`flocks.channel.builtin.dingtalk.send.send_message_app`. + +Multi-account support follows the same pattern as the Feishu channel: +each ``accounts.`` block spawns its own stream connection. +""" + +from __future__ import annotations + +import asyncio +from typing import Awaitable, Callable, Optional + +from flocks.channel.base import ( + ChannelCapabilities, + ChannelMeta, + ChannelPlugin, + ChatType, + DeliveryResult, + InboundMessage, + OutboundContext, +) +from flocks.channel.builtin.dingtalk.config import ( + list_account_configs, + resolve_account_config, + strip_target_prefix, +) +from flocks.utils.log import Log + +log = Log.create(service="channel.dingtalk") + + +class DingTalkChannel(ChannelPlugin): + """DingTalk channel — Stream Mode inbound, OAPI outbound.""" + + def __init__(self) -> None: + super().__init__() + self._runner_tasks: list[asyncio.Task] = [] + + # ------------------------------------------------------------------ + # Metadata + # ------------------------------------------------------------------ + + def meta(self) -> ChannelMeta: + return ChannelMeta( + id="dingtalk", + label="钉钉", + aliases=["dingding"], + order=30, + ) + + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities( + chat_types=[ChatType.DIRECT, ChatType.GROUP], + media=True, + threads=False, + reactions=False, + edit=False, + rich_text=True, + ) + + def validate_config(self, config: dict) -> Optional[str]: + accounts = list_account_configs(config, require_credentials=True) + if not accounts: + return ( + "Missing required config: appKey/appSecret (also accepted as " + "clientId/clientSecret), at top-level or under accounts." + ) + return None + + # ------------------------------------------------------------------ + # Outbound + # ------------------------------------------------------------------ + + async def send_text(self, ctx: OutboundContext) -> DeliveryResult: + """Send a text/markdown message via the DingTalk app-robot OAPI.""" + # Resolve via the package alias so test patches that target + # ``flocks.channel.builtin.dingtalk.send_message_app`` (the public + # surface) replace the same binding we look up here. + from flocks.channel.builtin import dingtalk as _dingtalk_pkg + from flocks.channel.builtin.dingtalk.client import DingTalkApiError + + send_message_app = _dingtalk_pkg.send_message_app + + if not ctx.to or not strip_target_prefix(ctx.to): + return DeliveryResult( + channel_id="dingtalk", + message_id="", + success=False, + error="DingTalk send requires 'to' (user: or chat:)", + ) + + try: + send_config = resolve_account_config(self._config or {}, ctx.account_id) + result = await send_message_app( + config=send_config, + to=ctx.to, + text=ctx.text, + account_id=ctx.account_id, + ) + self.record_message() + return DeliveryResult( + channel_id="dingtalk", + message_id=str(result.get("message_id", "")), + chat_id=result.get("chat_id"), + ) + except Exception as exc: + retryable = getattr(exc, "retryable", False) + if not retryable and not isinstance(exc, DingTalkApiError): + msg = str(exc).lower() + retryable = "rate limit" in msg or "timeout" in msg + log.warning("dingtalk.send_text.failed", { + "to": ctx.to, "error": str(exc), "retryable": retryable, + }) + return DeliveryResult( + channel_id="dingtalk", + message_id="", + success=False, + error=str(exc), + retryable=retryable, + ) + + @property + def text_chunk_limit(self) -> int: + return int((self._config or {}).get("textChunkLimit", 4000)) + + @property + def rate_limit(self) -> tuple[float, int]: + rate = (self._config or {}).get("rateLimit", 20.0) + burst = (self._config or {}).get("rateBurst", 5) + return float(rate), int(burst) + + def normalize_target(self, raw: str) -> Optional[str]: + return strip_target_prefix(raw) or None + + def target_hint(self) -> str: + return "user: 或 chat:" + + # ------------------------------------------------------------------ + # Inbound lifecycle + # ------------------------------------------------------------------ + + async def start( + self, + config: dict, + on_message: Callable[[InboundMessage], Awaitable[None]], + abort_event: Optional[asyncio.Event] = None, + ) -> None: + """Start one Stream Mode connection per configured account. + + Blocks until *abort_event* fires or every runner task exits. + + * Permanent auth failures (4xx from the gateway) are logged per + account and that account is dropped from the schedule, but + they do **not** propagate to the gateway — retrying with the + same bad credentials would be pointless. + * Other (transient) runner exceptions DO propagate so the + gateway's exponential-backoff reconnect policy can take over. + """ + from flocks.channel.builtin.dingtalk.stream import ( + DINGTALK_STREAM_AVAILABLE, + DingTalkStreamRunner, + ) + + self._config = config + self._on_message = on_message + + if not DINGTALK_STREAM_AVAILABLE: + raise RuntimeError( + "dingtalk-stream is not installed. " + "Run `pip install 'dingtalk-stream>=0.20'` to enable the DingTalk channel." + ) + + accounts = list_account_configs(config, require_credentials=True) + if not accounts: + log.warning("dingtalk.start.no_accounts") + return + + tasks: list[asyncio.Task] = [] + for account in accounts: + runner = DingTalkStreamRunner( + account_config=account, + on_message=on_message, + ) + if not runner.is_configured(): + log.warning("dingtalk.start.account_skipped", { + "account": runner.account_id, + "reason": "missing appKey/appSecret", + }) + continue + log.info("dingtalk.start.account", { + "account": runner.account_id, + "client_id": runner.client_id, + }) + tasks.append(asyncio.create_task( + runner.run(abort_event), + name=f"dingtalk-stream-{runner.account_id}", + )) + + self._runner_tasks = tasks + + if not tasks: + return + + try: + await self._wait_until_done(abort_event) + finally: + await self._cancel_runners() + + async def _wait_until_done(self, abort_event: Optional[asyncio.Event]) -> None: + """Wait for *all* runners to finish (or *abort_event* to fire). + + We deliberately wait for ``ALL_COMPLETED`` instead of + ``FIRST_COMPLETED`` so that a single dead account doesn't tear + down the still-healthy ones — multi-account configs must keep + the surviving connections up. + + Post-condition: returns *cleanly* only when one of the following + is true (anything else is re-raised so the gateway can react): + + * ``abort_event`` fired — the gateway asked us to stop. + * Every runner exited with a permanent auth failure — retrying + with the same bad credentials would be pointless. + + If runners are externally cancelled while ``abort_event`` is + still clear (which happens when ``plugin.stop()`` races against + a concurrent ``plugin.start()`` — see + :meth:`ChannelGateway.stop_channel`), we raise a transient + :class:`RuntimeError` so the gateway's exponential-backoff + reconnect loop kicks in and a fresh connection is established. + Returning ``None`` here would otherwise be mistaken for + "webhook / passive mode" by the gateway and leave the channel + permanently disconnected. + """ + from flocks.channel.builtin.dingtalk.stream import ( + DingTalkPermanentAuthError, + ) + + if abort_event is None: + results = await asyncio.gather( + *self._runner_tasks, return_exceptions=True, + ) + self._classify_and_raise( + results, + permanent_exc_type=DingTalkPermanentAuthError, + abort_set=False, + ) + return + + # Note: ``asyncio.gather(...)`` returns a ``_GatheringFuture`` — + # it must NOT be wrapped in ``asyncio.create_task`` (that helper + # rejects anything that is not a bare coroutine and would raise + # ``TypeError: a coroutine was expected``). ``asyncio.wait`` + # accepts Futures directly, which is what we want here. + abort_waiter = asyncio.ensure_future(abort_event.wait()) + runners_waiter = asyncio.gather( + *self._runner_tasks, return_exceptions=True, + ) + try: + await asyncio.wait( + {abort_waiter, runners_waiter}, + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + if not abort_waiter.done(): + abort_waiter.cancel() + try: + await abort_waiter + except (asyncio.CancelledError, Exception): + pass + + if runners_waiter.done() and not runners_waiter.cancelled(): + results = runners_waiter.result() + self._classify_and_raise( + results, + permanent_exc_type=DingTalkPermanentAuthError, + abort_set=abort_event.is_set(), + ) + + @staticmethod + def _classify_and_raise( + results: list, + *, + permanent_exc_type: type, + abort_set: bool, + ) -> None: + """Inspect runner exit reasons and re-raise when retry is needed. + + Decision matrix (in order): + + 1. Any transient (non-permanent, non-cancelled) exception → re-raise + it so the gateway treats it as a connection error and retries. + 2. Any ``CancelledError`` while ``abort_set`` is False → external + ``plugin.stop()`` race; raise a transient ``RuntimeError`` so + the gateway reconnects (otherwise a clean return would be + mis-classified as passive/webhook mode). + 3. Otherwise return silently — every account either failed + permanently (logged elsewhere) or shut down cleanly because + the gateway told us to. + """ + had_external_cancel = False + for result in results: + if not isinstance(result, BaseException): + continue + if isinstance(result, permanent_exc_type): + continue + if isinstance(result, asyncio.CancelledError): + if not abort_set: + had_external_cancel = True + continue + raise result + + if had_external_cancel: + log.warning("dingtalk.start.external_cancel", { + "hint": ( + "runner cancelled without abort signal — likely a " + "concurrent restart race; surfacing as transient " + "error so the gateway reconnects" + ), + }) + raise RuntimeError( + "DingTalk runner cancelled without abort signal " + "(concurrent stop/restart race) — reconnecting" + ) + + async def _cancel_runners(self) -> None: + for task in self._runner_tasks: + if not task.done(): + task.cancel() + if self._runner_tasks: + await asyncio.gather(*self._runner_tasks, return_exceptions=True) + self._runner_tasks = [] + + async def stop(self) -> None: + from flocks.channel.builtin.dingtalk.client import close_http_client + + await self._cancel_runners() + try: + await close_http_client() + except Exception: + pass + # Connection-status bookkeeping is owned by the gateway — see + # ``ChannelGateway._run_with_reconnect``; calling mark_disconnected + # here would only race with the gateway's own call. + + +__all__ = ["DingTalkChannel"] diff --git a/flocks/channel/builtin/dingtalk/stream.py b/flocks/channel/builtin/dingtalk/stream.py new file mode 100644 index 000000000..89682eff4 --- /dev/null +++ b/flocks/channel/builtin/dingtalk/stream.py @@ -0,0 +1,714 @@ +""" +DingTalk Stream Mode inbound (Python-native). + +Wraps the official ``dingtalk-stream`` SDK (>= 0.20) so that incoming +ChatBot messages are converted into Flocks :class:`InboundMessage` +instances and forwarded to the gateway dispatcher. + +This module is the inbound counterpart to :mod:`flocks.channel.builtin.dingtalk.send` +and replaces the legacy Node.js ``runner.ts`` connector. + +Design notes +------------ +* One :class:`DingTalkStreamRunner` per account. Each runner owns a + long-lived WebSocket connection to ``wss://wss-open.dingtalk.com`` + managed by ``dingtalk_stream.DingTalkStreamClient``. +* SDK >= 0.20 is fully async; ``ChatbotHandler.process()`` must return + ``(status_code, str)`` quickly and dispatch the heavy lifting in the + background, otherwise heartbeats stall and the connection drops. +* The SDK's own ``start()`` swallows every error from + ``open_connection()`` (returns ``None``) and retries forever — so + bad credentials would silently spin without ever surfacing. We work + around it with a one-shot pre-flight against the same gateway endpoint + before delegating to the SDK; permanent 4xx auth failures abort the + reconnect loop instead of looping endlessly. +* Group-chat gating mirrors Feishu / WeCom: by default groups require + an explicit @mention or an entry in ``free_response_chats``; DMs are + unconditional aside from the optional ``allowed_users`` allow-list. +""" + +from __future__ import annotations + +import asyncio +import json +import platform +import re +import uuid +from typing import Any, Awaitable, Callable, Optional + +import httpx + +from flocks.channel.base import ChatType, InboundMessage +from flocks.utils.log import Log + +log = Log.create(service="channel.dingtalk.stream") + +try: + import dingtalk_stream + from dingtalk_stream import ChatbotMessage + from dingtalk_stream.frames import AckMessage, CallbackMessage + + DINGTALK_STREAM_AVAILABLE = True +except ImportError: # pragma: no cover - exercised when the optional dep is missing + DINGTALK_STREAM_AVAILABLE = False + dingtalk_stream = None # type: ignore[assignment] + ChatbotMessage = None # type: ignore[assignment] + CallbackMessage = None # type: ignore[assignment] + AckMessage = type( + "AckMessage", + (), + {"STATUS_OK": 200, "STATUS_SYSTEM_EXCEPTION": 500}, + ) # type: ignore[assignment] + + +# Reconnection backoff schedule (seconds) — matches hermes-agent's defaults. +_RECONNECT_BACKOFF = [2, 5, 10, 30, 60] + +# Group conversation type as reported by DingTalk in ``conversationType``. +_CONVERSATION_TYPE_GROUP = "2" + +# Pre-flight target for the Stream Mode WebSocket gateway. Calling this +# endpoint with bad credentials returns a structured 4xx body that lets +# us distinguish "wrong key/secret" from "transient network blip". +_GATEWAY_OPEN_URL = "https://api.dingtalk.com/v1.0/gateway/connections/open" +_GATEWAY_PREFLIGHT_TIMEOUT = 10.0 + +# DingTalk error codes that mean "the credentials / app are not valid" +# — retrying with the same secret will never succeed. Codes are +# documented at https://open.dingtalk.com/document/orgapp/error-code . +_PERMANENT_AUTH_CODES = frozenset({ + "invalidauthentication", # bad clientSecret + "invalidappkey", # bad clientId + "appnotexist", # app deleted / wrong id + "forbidden.accesstoken", # app revoked + "forbidden", # generic forbidden + "subscription.notpermitted", # Stream Mode not enabled for this app + "unauthorizedclient", +}) + + +class DingTalkPermanentAuthError(RuntimeError): + """Raised when the DingTalk gateway rejects the credentials with a + 4xx status that retrying cannot fix (bad clientId/clientSecret, app + revoked, Stream Mode subscription not enabled, …). + """ + + def __init__( + self, + message: str, + *, + code: Optional[str] = None, + http_status: Optional[int] = None, + ) -> None: + super().__init__(message) + self.code = code + self.http_status = http_status + + +OnMessage = Callable[[InboundMessage], Awaitable[None]] + + +# --------------------------------------------------------------------------- +# Pre-flight: detect permanent credential failures up front +# --------------------------------------------------------------------------- + + +async def _preflight_open_connection( + *, + client_id: str, + client_secret: str, + timeout: float = _GATEWAY_PREFLIGHT_TIMEOUT, +) -> None: + """Probe the Stream Mode gateway with the given credentials. + + Returns silently on success (the returned ticket is single-use, so + we deliberately throw it away — the SDK will mint its own when it + actually opens the WebSocket). Raises + :class:`DingTalkPermanentAuthError` for 4xx responses with + auth-related error codes; any other transport / 5xx error is + re-raised as-is so the caller can apply normal retry semantics. + """ + payload = { + "clientId": client_id, + "clientSecret": client_secret, + "subscriptions": [], + "ua": "flocks-dingtalk-preflight/1.0", + } + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": ( + f"flocks-dingtalk-preflight/1.0 " + f"Python/{platform.python_version()}" + ), + } + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(_GATEWAY_OPEN_URL, json=payload, headers=headers) + + if resp.status_code < 400: + return + + body: dict = {} + try: + body = resp.json() if resp.content else {} + except (ValueError, json.JSONDecodeError): + body = {} + + code = str(body.get("code") or "").strip() + message = str(body.get("message") or resp.text or "").strip() + + # 4xx with a recognised auth code → permanent failure. + if 400 <= resp.status_code < 500: + if code.lower() in _PERMANENT_AUTH_CODES or resp.status_code in (401, 403): + raise DingTalkPermanentAuthError( + f"DingTalk gateway rejected credentials: " + f"HTTP {resp.status_code} {code or ''}: {message}", + code=code or None, + http_status=resp.status_code, + ) + # Other 4xx (e.g. 400 with a transient validation error) — let + # the caller retry; the SDK's own loop may also recover. + raise httpx.HTTPStatusError( + f"DingTalk gateway preflight failed: HTTP {resp.status_code}: {message}", + request=resp.request, + response=resp, + ) + + raise httpx.HTTPStatusError( + f"DingTalk gateway preflight failed: HTTP {resp.status_code}: {message}", + request=resp.request, + response=resp, + ) + + +# --------------------------------------------------------------------------- +# Gating helpers (mirrors hermes-agent's DingTalkAdapter logic) +# --------------------------------------------------------------------------- + + +def _truthy(value: Any) -> bool: + if isinstance(value, str): + return value.lower() in ("true", "1", "yes", "on") + return bool(value) + + +def _coerce_str_list(raw: Any) -> list[str]: + if raw is None: + return [] + if isinstance(raw, list): + return [str(part).strip() for part in raw if str(part).strip()] + if isinstance(raw, str): + try: + loaded = json.loads(raw) + if isinstance(loaded, list): + return [str(part).strip() for part in loaded if str(part).strip()] + except (json.JSONDecodeError, ValueError): + pass + return [part.strip() for part in raw.split(",") if part.strip()] + return [] + + +def _compile_mention_patterns(raw: Any) -> list[re.Pattern]: + patterns = _coerce_str_list(raw) + compiled: list[re.Pattern] = [] + for pattern in patterns: + try: + compiled.append(re.compile(pattern, re.IGNORECASE)) + except re.error as exc: + log.warning("dingtalk.stream.mention_pattern_invalid", { + "pattern": pattern, "error": str(exc), + }) + return compiled + + +# --------------------------------------------------------------------------- +# Message extraction +# --------------------------------------------------------------------------- + + +def _extract_text(message: Any) -> str: + """Pull plain text out of a ChatbotMessage across SDK shapes. + + SDK >= 0.20 exposes ``message.text`` as a ``TextContent`` dataclass + (``str(text)`` returns ``"TextContent(content=...)"`` — useless), + while older versions used a plain dict. Rich-text payloads moved + from ``message.rich_text`` to ``message.rich_text_content``. + """ + text = getattr(message, "text", None) or "" + if hasattr(text, "content"): + content = (text.content or "").strip() + elif isinstance(text, dict): + content = str(text.get("content") or "").strip() + else: + content = str(text).strip() + + if content: + return content + + rich_text = ( + getattr(message, "rich_text_content", None) + or getattr(message, "rich_text", None) + ) + if not rich_text: + return "" + + rich_list = getattr(rich_text, "rich_text_list", None) or rich_text + if not isinstance(rich_list, list): + return "" + + parts: list[str] = [] + for item in rich_list: + if isinstance(item, dict): + piece = item.get("text") or item.get("content") or "" + if piece: + parts.append(piece) + else: + piece = getattr(item, "text", None) + if piece: + parts.append(piece) + return " ".join(parts).strip() + + +def _extract_media_url(message: Any) -> Optional[str]: + """Return the first media reference (download_code or URL) in *message*. + + DingTalk delivers a ``download_code`` that must be exchanged for a + short-lived URL via the OAPI; here we pass the raw code through so + downstream tools can resolve it lazily, matching what other channels + do for opaque media handles. + """ + image_content = getattr(message, "image_content", None) + if image_content: + code = getattr(image_content, "download_code", None) or getattr( + image_content, "downloadCode", None + ) + if code: + return str(code) + + rich_text = getattr(message, "rich_text_content", None) or getattr( + message, "rich_text", None + ) + if rich_text: + rich_list = getattr(rich_text, "rich_text_list", None) or rich_text + if isinstance(rich_list, list): + for item in rich_list: + if isinstance(item, dict): + code = ( + item.get("downloadCode") + or item.get("download_code") + or item.get("pictureDownloadCode") + ) + if code: + return str(code) + return None + + +# --------------------------------------------------------------------------- +# Gating decisions per inbound message +# --------------------------------------------------------------------------- + + +class _MessageGate: + """Encapsulates the require_mention / allowed_users / mention_patterns + rules so the SDK handler stays thin. + """ + + def __init__(self, account_config: dict) -> None: + self.require_mention = _truthy( + account_config.get("requireMention", account_config.get("require_mention", False)) + ) + self.free_response_chats: set[str] = set( + _coerce_str_list( + account_config.get("freeResponseChats") + or account_config.get("free_response_chats") + ) + ) + self.mention_patterns = _compile_mention_patterns( + account_config.get("mentionPatterns") + or account_config.get("mention_patterns") + ) + self.allowed_users: set[str] = { + item.lower() + for item in _coerce_str_list( + account_config.get("allowedUsers") + or account_config.get("allowed_users") + ) + } + + def is_user_allowed(self, sender_id: str, sender_staff_id: str) -> bool: + if not self.allowed_users or "*" in self.allowed_users: + return True + candidates = {(sender_id or "").lower(), (sender_staff_id or "").lower()} + candidates.discard("") + return bool(candidates & self.allowed_users) + + def should_process( + self, + message: Any, + text: str, + is_group: bool, + chat_id: str, + ) -> bool: + if not is_group: + return True + if chat_id and chat_id in self.free_response_chats: + return True + if not self.require_mention: + return True + if bool(getattr(message, "is_in_at_list", False)): + return True + if text and self.mention_patterns: + return any(p.search(text) for p in self.mention_patterns) + return False + + +# --------------------------------------------------------------------------- +# Conversion: ChatbotMessage → InboundMessage +# --------------------------------------------------------------------------- + + +def chatbot_message_to_inbound( + message: Any, + *, + channel_id: str, + account_id: str, +) -> Optional[InboundMessage]: + """Convert a parsed ``dingtalk_stream.ChatbotMessage`` into an InboundMessage. + + Returns ``None`` for empty messages so the handler can drop them + silently rather than spamming the dispatcher. + """ + text = _extract_text(message) + media_url = _extract_media_url(message) + if not text and not media_url: + return None + + conversation_id = str(getattr(message, "conversation_id", "") or "") + conversation_type = str(getattr(message, "conversation_type", "1") or "1") + is_group = conversation_type == _CONVERSATION_TYPE_GROUP + + sender_id = str(getattr(message, "sender_id", "") or "") + sender_staff_id = str(getattr(message, "sender_staff_id", "") or "") + sender_nick = str(getattr(message, "sender_nick", "") or sender_id) + + # DingTalk delivers a ``conversation_id`` (cid…) for *both* DMs and + # group chats — but only group chats can be replied to via + # ``/v1.0/robot/groupMessages/send``. DMs MUST be sent to the + # user's ``staffId`` via ``/v1.0/robot/oToMessages/batchSend``; + # routing a DM through the group endpoint fails with + # ``robot 不存在``. ``resolve_target_kind`` infers the route from + # the ``chat_id`` prefix (``cid`` → group, otherwise → user), so + # picking the right id here is what keeps outbound replies routed + # correctly. + if is_group: + chat_id = conversation_id or sender_staff_id or sender_id + else: + chat_id = sender_staff_id or sender_id or conversation_id + chat_type = ChatType.GROUP if is_group else ChatType.DIRECT + mentioned = bool(getattr(message, "is_in_at_list", False)) if is_group else False + + msg_id = str(getattr(message, "message_id", "") or uuid.uuid4().hex) + + return InboundMessage( + channel_id=channel_id, + account_id=account_id, + message_id=msg_id, + sender_id=sender_staff_id or sender_id, + sender_name=sender_nick, + chat_id=chat_id, + chat_type=chat_type, + text=text, + media_url=media_url, + mentioned=mentioned, + mention_text="", + raw=message, + ) + + +# --------------------------------------------------------------------------- +# Stream client wrapper +# --------------------------------------------------------------------------- + + +class DingTalkStreamRunner: + """Owns one ``DingTalkStreamClient`` and forwards inbound messages. + + The runner only does inbound conversion + gating; connection-status + bookkeeping (``mark_connected`` / ``mark_disconnected`` / + ``record_message``) is the gateway's responsibility. See + :meth:`flocks.channel.gateway.manager.ChannelGateway._run_with_reconnect` + and ``_make_on_message`` for where those hooks fire. + """ + + def __init__( + self, + *, + account_config: dict, + on_message: OnMessage, + ) -> None: + self.account_config = account_config + self.account_id = str(account_config.get("_account_id") or "default") + self.client_id = str( + account_config.get("appKey") or account_config.get("clientId") or "" + ) + self.client_secret = str( + account_config.get("appSecret") + or account_config.get("clientSecret") + or "" + ) + + self._on_message = on_message + + self._gate = _MessageGate(account_config) + self._stream_client: Any = None + self._stream_task: Optional[asyncio.Task] = None + self._running = False + self._dispatch_tasks: set[asyncio.Task] = set() + # Set by ``_run_with_reconnect`` when the gateway rejects the + # credentials with a 4xx — surfaced from ``run()`` after shutdown + # so the channel layer can stop retrying that account. + self._permanent_error: Optional[DingTalkPermanentAuthError] = None + + def is_configured(self) -> bool: + return bool(self.client_id and self.client_secret) + + async def run(self, abort_event: Optional[asyncio.Event] = None) -> None: + """Connect and block until *abort_event* is set or run() is cancelled.""" + if not DINGTALK_STREAM_AVAILABLE: + raise RuntimeError( + "dingtalk-stream is not installed. " + "Run `pip install 'dingtalk-stream>=0.20'` to enable the DingTalk channel." + ) + if not self.is_configured(): + raise ValueError( + "DingTalk account missing appKey/appSecret (also accepted as " + "clientId/clientSecret)" + ) + + credential = dingtalk_stream.Credential(self.client_id, self.client_secret) + self._stream_client = dingtalk_stream.DingTalkStreamClient(credential) + + handler = _IncomingHandler(self) + self._stream_client.register_callback_handler( + dingtalk_stream.ChatbotMessage.TOPIC, handler + ) + + self._running = True + self._stream_task = asyncio.create_task(self._run_with_reconnect()) + try: + if abort_event is None: + await self._stream_task + else: + await self._wait_for_abort(abort_event) + finally: + await self._shutdown() + + # Surface permanent auth failure AFTER cleanup so the channel + # can drop this account from the reconnect schedule. + if self._permanent_error is not None: + raise self._permanent_error + + async def _wait_for_abort(self, abort_event: asyncio.Event) -> None: + abort_waiter = asyncio.create_task(abort_event.wait()) + done, pending = await asyncio.wait( + {abort_waiter, self._stream_task} if self._stream_task else {abort_waiter}, + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + + async def _run_with_reconnect(self) -> None: + backoff_idx = 0 + while self._running: + # Pre-flight credential check: surfaces 4xx auth errors that + # the SDK would otherwise silently swallow inside its own + # forever-retry loop. Performed every iteration so that an + # app revoked mid-flight also breaks us out cleanly. + try: + await _preflight_open_connection( + client_id=self.client_id, + client_secret=self.client_secret, + ) + except DingTalkPermanentAuthError as exc: + log.error("dingtalk.stream.permanent_auth_failure", { + "account": self.account_id, + "code": exc.code, + "http_status": exc.http_status, + "error": str(exc), + }) + self._permanent_error = exc + return + except asyncio.CancelledError: + return + except Exception as exc: + # Transient pre-flight failure (network blip, 5xx, …) — + # log and fall through to the SDK, which will re-attempt + # ``open_connection`` with its own loop. + log.warning("dingtalk.stream.preflight_transient_error", { + "account": self.account_id, "error": str(exc), + }) + + try: + log.debug("dingtalk.stream.starting", {"account": self.account_id}) + await self._stream_client.start() + except asyncio.CancelledError: + return + except Exception as exc: + if not self._running: + return + log.warning("dingtalk.stream.error", { + "account": self.account_id, "error": str(exc), + }) + + if not self._running: + return + + delay = _RECONNECT_BACKOFF[ + min(backoff_idx, len(_RECONNECT_BACKOFF) - 1) + ] + log.info("dingtalk.stream.reconnecting", { + "account": self.account_id, "delay_seconds": delay, + }) + try: + await asyncio.sleep(delay) + except asyncio.CancelledError: + return + backoff_idx += 1 + + async def _shutdown(self) -> None: + self._running = False + + client = self._stream_client + websocket = getattr(client, "websocket", None) if client else None + if websocket is not None: + try: + await websocket.close() + except Exception: + pass + + if self._stream_task: + if client is not None and hasattr(client, "close"): + try: + await asyncio.to_thread(client.close) + except Exception: + pass + self._stream_task.cancel() + try: + await asyncio.wait_for(self._stream_task, timeout=5.0) + except (asyncio.CancelledError, asyncio.TimeoutError): + pass + self._stream_task = None + + for task in list(self._dispatch_tasks): + task.cancel() + if self._dispatch_tasks: + await asyncio.gather(*self._dispatch_tasks, return_exceptions=True) + self._dispatch_tasks.clear() + + self._stream_client = None + + # -- inbound dispatch ------------------------------------------------ + + async def _dispatch(self, chatbot_msg: Any) -> None: + try: + text = _extract_text(chatbot_msg) + sender_id = str(getattr(chatbot_msg, "sender_id", "") or "") + sender_staff_id = str(getattr(chatbot_msg, "sender_staff_id", "") or "") + + if not self._gate.is_user_allowed(sender_id, sender_staff_id): + log.debug("dingtalk.stream.user_not_allowed", { + "account": self.account_id, + "sender_id": sender_id, + "sender_staff_id": sender_staff_id, + }) + return + + conversation_type = str( + getattr(chatbot_msg, "conversation_type", "1") or "1" + ) + is_group = conversation_type == _CONVERSATION_TYPE_GROUP + chat_id = str(getattr(chatbot_msg, "conversation_id", "") or "") or sender_id + + if not self._gate.should_process(chatbot_msg, text, is_group, chat_id): + log.debug("dingtalk.stream.gate_dropped", { + "account": self.account_id, "chat_id": chat_id, + }) + return + + inbound = chatbot_message_to_inbound( + chatbot_msg, + channel_id="dingtalk", + account_id=self.account_id, + ) + if inbound is None: + return + + await self._on_message(inbound) + except Exception: + log.exception("dingtalk.stream.dispatch_error", { + "account": self.account_id, + }) + + def _spawn_dispatch(self, chatbot_msg: Any) -> None: + task = asyncio.create_task(self._dispatch(chatbot_msg)) + self._dispatch_tasks.add(task) + task.add_done_callback(self._dispatch_tasks.discard) + + +# --------------------------------------------------------------------------- +# SDK callback handler +# --------------------------------------------------------------------------- + + +class _IncomingHandler( + dingtalk_stream.ChatbotHandler if DINGTALK_STREAM_AVAILABLE else object # type: ignore[misc] +): + """``ChatbotHandler`` subclass that converts SDK callbacks → InboundMessage. + + The SDK invokes ``process()`` once per inbound frame; we MUST ack + quickly so heartbeats keep flowing, otherwise the connection is + torn down server-side. The actual gating + dispatch runs in a + background task (tracked on the runner so shutdown can cancel it). + """ + + def __init__(self, runner: DingTalkStreamRunner) -> None: + if DINGTALK_STREAM_AVAILABLE: + super().__init__() + self._runner = runner + + async def process(self, message: Any): # type: ignore[override] + try: + data = getattr(message, "data", None) + if isinstance(data, str): + data = json.loads(data) + if not isinstance(data, dict): + return AckMessage.STATUS_OK, "OK" + + chatbot_msg = ChatbotMessage.from_dict(data) + + # SDKs across versions disagree on whether ``session_webhook`` + # and ``isInAtList`` are mapped automatically — backfill from + # the raw payload when they are missing. + if not getattr(chatbot_msg, "session_webhook", None): + webhook = data.get("sessionWebhook") or data.get("session_webhook") + if webhook: + chatbot_msg.session_webhook = webhook + + if not getattr(chatbot_msg, "is_in_at_list", False): + if data.get("isInAtList"): + chatbot_msg.is_in_at_list = True + + self._runner._spawn_dispatch(chatbot_msg) + except Exception: + log.exception("dingtalk.stream.handler_error") + return AckMessage.STATUS_SYSTEM_EXCEPTION, "error" + return AckMessage.STATUS_OK, "OK" + + +__all__ = [ + "DINGTALK_STREAM_AVAILABLE", + "DingTalkPermanentAuthError", + "DingTalkStreamRunner", + "chatbot_message_to_inbound", +] diff --git a/flocks/channel/registry.py b/flocks/channel/registry.py index a690fd641..aff20c7da 100644 --- a/flocks/channel/registry.py +++ b/flocks/channel/registry.py @@ -76,17 +76,14 @@ def reset(self) -> None: # --- internal --- def _register_builtin_channels(self) -> None: + from flocks.channel.builtin.dingtalk.channel import DingTalkChannel from flocks.channel.builtin.feishu.channel import FeishuChannel from flocks.channel.builtin.telegram.channel import TelegramChannel from flocks.channel.builtin.wecom.channel import WeComChannel self.register(FeishuChannel()) self.register(WeComChannel()) self.register(TelegramChannel()) - # DingTalk: inbound is owned by the project-local plugin at - # .flocks/plugins/channels/dingtalk/dingtalk.py (Node.js connector). - # The outbound send library lives in flocks.channel.builtin.dingtalk - # and is consumed directly by that plugin's send_text — no builtin - # ChannelPlugin is registered here to avoid id collisions. + self.register(DingTalkChannel()) def _register_plugin_extension_point(self) -> None: from flocks.plugin import PluginLoader, ExtensionPoint diff --git a/pyproject.toml b/pyproject.toml index 1f262eba1..d149a9905 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,8 @@ dependencies = [ "wecom-aibot-sdk>=1.0.0", # Feishu / Lark (WebSocket long-connection + Open API) "lark-oapi>=1.3.0", + # DingTalk Stream Mode (WebSocket long-connection) + "dingtalk-stream>=0.20", "python-socks>=2.8.1", "pytz>=2026.1.post1", # docs parser @@ -77,7 +79,7 @@ dependencies = [ "olefile>=0.47", "defusedxml>=0.7.1", "markdown>=3.10.2", - "jinja2>=3.1.6" + "jinja2>=3.1.6", ] [dependency-groups] diff --git a/tests/channel/test_dingtalk.py b/tests/channel/test_dingtalk.py index f1433bb52..34a20cc9b 100644 --- a/tests/channel/test_dingtalk.py +++ b/tests/channel/test_dingtalk.py @@ -1,24 +1,18 @@ """ -Tests for the DingTalk active-outbound send library. +Tests for the DingTalk channel — Stream Mode inbound + OAPI outbound. Layout: - - send library → flocks.channel.builtin.dingtalk.{config,client,send} - - inbound owner → .flocks/plugins/channels/dingtalk/dingtalk.py (Node.js) - -Only the OAPI app-robot ("stream/app push") path is supported; custom -group-robot incoming webhooks are intentionally out of scope. - -The builtin package does NOT register a ChannelPlugin (to avoid id -collisions with the local plugin), so registry-side tests are absent. -The local Node.js plugin is owned separately and not exercised here. + - send library → flocks.channel.builtin.dingtalk.{config,client,send} + - stream inbound → flocks.channel.builtin.dingtalk.stream + - channel plugin → flocks.channel.builtin.dingtalk.channel.DingTalkChannel """ from __future__ import annotations +import asyncio +import contextlib import importlib.util import json -import sys -from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, patch @@ -342,81 +336,59 @@ def test_success_payload_passes_through(self): # ------------------------------------------------------------------ -# Builtin package no longer registers a ChannelPlugin +# Builtin DingTalk channel plugin (Stream Mode) # ------------------------------------------------------------------ -class TestBuiltinHasNoChannelClass: - def test_no_dingtalk_in_builtin_registry(self): +class TestBuiltinChannelPlugin: + def test_dingtalk_in_builtin_registry(self): from flocks.channel.registry import ChannelRegistry reg = ChannelRegistry() reg._register_builtin_channels() - # The builtin package intentionally exposes only a send library; - # the dingtalk id is owned by the project-local plugin. - assert reg.get("dingtalk") is None + plugin = reg.get("dingtalk") + assert plugin is not None, "DingTalkChannel must be registered as a builtin" + assert plugin.meta().id == "dingtalk" - def test_builtin_package_has_no_channel_module(self): + def test_builtin_channel_module_exists(self): spec = importlib.util.find_spec( "flocks.channel.builtin.dingtalk.channel" ) - assert spec is None - - -# ------------------------------------------------------------------ -# Local plugin send_text — delegates to send_message_app -# ------------------------------------------------------------------ - -# The local plugin lives under .flocks/plugins/channels/dingtalk/dingtalk.py; -# load it by file path because that directory is not on sys.path during tests. -_LOCAL_PLUGIN_PATH = ( - Path(__file__).resolve().parents[2] - / ".flocks/plugins/channels/dingtalk/dingtalk.py" -) - - -def _load_local_plugin_module(): - spec = importlib.util.spec_from_file_location( - "_test_dingtalk_local_plugin", _LOCAL_PLUGIN_PATH - ) - assert spec and spec.loader, f"cannot load {_LOCAL_PLUGIN_PATH}" - mod = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = mod - spec.loader.exec_module(mod) - return mod - - -class TestLocalPluginSendText: - """Active outbound (channel_message tool) goes through send_text.""" - - def _make_plugin(self, **config): - mod = _load_local_plugin_module() - plugin = mod.DingTalkChannel() - plugin._config = config - # Bypass the live `Config.get()` lookup added in send_text; tests - # supply the channel config directly. - async def _fake_resolve(self=plugin): - return dict(config) - plugin._resolve_outbound_config = _fake_resolve # type: ignore[assignment] - return plugin + assert spec is not None + + def test_validate_config_accepts_client_id_alias(self): + from flocks.channel.builtin.dingtalk import DingTalkChannel + plugin = DingTalkChannel() + assert plugin.validate_config({ + "clientId": "k", "clientSecret": "s", + }) is None + + def test_validate_config_rejects_missing_credentials(self): + from flocks.channel.builtin.dingtalk import DingTalkChannel + plugin = DingTalkChannel() + error = plugin.validate_config({}) + assert error is not None + assert "appKey" in error or "credentials" in error.lower() + + def test_meta_aliases(self): + from flocks.channel.builtin.dingtalk import DingTalkChannel + meta = DingTalkChannel().meta() + assert "dingding" in meta.aliases @pytest.mark.asyncio async def test_send_text_delegates_to_send_message_app(self): - plugin = self._make_plugin( - clientId="dingkey", - clientSecret="secret", - robotCode="dingrobot", - ) + from flocks.channel.builtin.dingtalk import DingTalkChannel + plugin = DingTalkChannel() + plugin._config = {"clientId": "k", "clientSecret": "s"} ctx = OutboundContext( channel_id="dingtalk", to="user:staff_001", - text="hello from rex", - account_id="default", + text="hello", ) - sent_kwargs = {} + captured: dict = {} async def fake_send(**kwargs): - sent_kwargs.update(kwargs) - return {"message_id": "mid_xxx", "chat_id": "staff_001"} + captured.update(kwargs) + return {"message_id": "mid", "chat_id": "staff_001"} with patch( "flocks.channel.builtin.dingtalk.send_message_app", @@ -425,99 +397,445 @@ async def fake_send(**kwargs): result = await plugin.send_text(ctx) assert result.success is True - assert result.message_id == "mid_xxx" - assert result.chat_id == "staff_001" - assert sent_kwargs["to"] == "user:staff_001" - assert sent_kwargs["text"] == "hello from rex" - # The plugin must forward its full config (so robotCode reaches the lib). - assert sent_kwargs["config"]["robotCode"] == "dingrobot" + assert result.message_id == "mid" + assert captured["to"] == "user:staff_001" + assert captured["text"] == "hello" @pytest.mark.asyncio - async def test_send_text_works_without_explicit_robot_code(self): - """robotCode defaults to clientId/appKey — no extra config needed.""" - plugin = self._make_plugin(clientId="dingkey", clientSecret="s") - ctx = OutboundContext(channel_id="dingtalk", to="user:staff_001", text="hi") + async def test_send_text_rejects_empty_target(self): + from flocks.channel.builtin.dingtalk import DingTalkChannel + plugin = DingTalkChannel() + plugin._config = {"clientId": "k", "clientSecret": "s"} + ctx = OutboundContext(channel_id="dingtalk", to="", text="x") - sent_kwargs = {} + result = await plugin.send_text(ctx) - async def fake_send(**kwargs): - sent_kwargs.update(kwargs) - return {"message_id": "m", "chat_id": "staff_001"} + assert result.success is False + assert "to" in (result.error or "").lower() - with patch( - "flocks.channel.builtin.dingtalk.send_message_app", - new=fake_send, - ): - result = await plugin.send_text(ctx) + @pytest.mark.asyncio + async def test_wait_until_done_handles_gather_future(self): + """Regression: ``_wait_until_done`` must not pass the + ``_GatheringFuture`` returned by ``asyncio.gather()`` to + ``asyncio.create_task``. Symptom in production: - assert result.success is True - # The plugin must NOT inject robotCode itself; the send library - # resolves it from clientId via resolve_account_credentials. - assert "robotCode" not in sent_kwargs["config"] + "a coroutine was expected, got <_GatheringFuture pending>" + + was raised on every reconnect, looping the gateway forever. + """ + from flocks.channel.builtin.dingtalk import DingTalkChannel + + plugin = DingTalkChannel() + + async def _quick_runner(): + return None + + plugin._runner_tasks = [ + asyncio.create_task(_quick_runner()), + asyncio.create_task(_quick_runner()), + ] + abort_event = asyncio.Event() + + # Should complete cleanly when all runners finish — no TypeError. + await asyncio.wait_for( + plugin._wait_until_done(abort_event), timeout=2.0, + ) @pytest.mark.asyncio - async def test_send_text_missing_target_returns_error(self): - plugin = self._make_plugin(clientId="k", clientSecret="s") - ctx = OutboundContext(channel_id="dingtalk", to="", text="hi") + async def test_wait_until_done_returns_when_abort_fires(self): + from flocks.channel.builtin.dingtalk import DingTalkChannel - result = await plugin.send_text(ctx) + plugin = DingTalkChannel() - assert result.success is False - assert "to" in (result.error or "").lower() + async def _slow_runner(): + await asyncio.sleep(60) + + plugin._runner_tasks = [asyncio.create_task(_slow_runner())] + abort_event = asyncio.Event() + + async def _fire_abort(): + await asyncio.sleep(0.05) + abort_event.set() + + asyncio.create_task(_fire_abort()) + await asyncio.wait_for( + plugin._wait_until_done(abort_event), timeout=2.0, + ) + + # Caller is responsible for cancelling runners; verify the + # waiter returns even though the runner is still pending. + plugin._runner_tasks[0].cancel() + with contextlib.suppress(asyncio.CancelledError): + await plugin._runner_tasks[0] @pytest.mark.asyncio - async def test_resolve_outbound_config_reads_live_global_config(self): - """send_text must NOT depend on self._config: PluginLoader can register - a fresh DingTalkChannel after start() ran on the original instance, - leaving self._config = None on the one outbound actually picks up. + async def test_wait_until_done_suppresses_permanent_auth_error(self): + """Permanent auth errors must NOT propagate from _wait_until_done + — otherwise the gateway would reconnect indefinitely with the + same bad credentials. """ - mod = _load_local_plugin_module() - plugin = mod.DingTalkChannel() # NB: no plugin._config set on purpose + from flocks.channel.builtin.dingtalk import DingTalkChannel + from flocks.channel.builtin.dingtalk.stream import ( + DingTalkPermanentAuthError, + ) - from flocks.config.config import ChannelConfig as _CC + plugin = DingTalkChannel() - live_cfg = _CC( - enabled=True, - **{ - "clientId": "live_key", - "clientSecret": "live_secret", - }, + async def _failing_runner(): + raise DingTalkPermanentAuthError( + "bad creds", code="InvalidAuthentication", http_status=401, + ) + + plugin._runner_tasks = [asyncio.create_task(_failing_runner())] + abort_event = asyncio.Event() + + # Must return cleanly, not raise. + await asyncio.wait_for( + plugin._wait_until_done(abort_event), timeout=1.0, ) - # _resolve_outbound_config inspects ``cfg.channels`` directly so it - # can distinguish "not configured" from a synthesised default — the - # stub must therefore expose the same attribute. - class _FakeCfgInfo: - channels = {"dingtalk": live_cfg} + @pytest.mark.asyncio + async def test_wait_until_done_propagates_transient_error(self): + from flocks.channel.builtin.dingtalk import DingTalkChannel - async def _fake_get(): - return _FakeCfgInfo() + plugin = DingTalkChannel() - with patch("flocks.config.config.Config.get", new=_fake_get): - data = await plugin._resolve_outbound_config() + async def _failing_runner(): + raise RuntimeError("network hiccup") - assert data["clientId"] == "live_key" - assert data["clientSecret"] == "live_secret" + plugin._runner_tasks = [asyncio.create_task(_failing_runner())] + abort_event = asyncio.Event() + + with pytest.raises(RuntimeError, match="network hiccup"): + await asyncio.wait_for( + plugin._wait_until_done(abort_event), timeout=1.0, + ) @pytest.mark.asyncio - async def test_send_text_propagates_dingtalk_api_error_as_failure(self): - plugin = self._make_plugin(clientId="k", clientSecret="s") - ctx = OutboundContext(channel_id="dingtalk", to="user:x", text="hi") + async def test_wait_until_done_treats_external_cancel_as_transient(self): + """Regression: when ``plugin.stop()`` cancels runners while + ``abort_event`` is still clear (concurrent restart race in the + gateway), ``_wait_until_done`` must surface a transient error + — NOT return cleanly. + + Returning cleanly there was the production bug observed in + ``backend.log``: the gateway's ``_run_with_reconnect`` then + misinterpreted the fast clean return as "webhook / passive + mode" and parked on ``abort_event.wait()`` forever, so DingTalk + never reconnected and stopped receiving messages. + """ + from flocks.channel.builtin.dingtalk import DingTalkChannel + + plugin = DingTalkChannel() + + async def _slow_runner(): + await asyncio.sleep(60) + + plugin._runner_tasks = [asyncio.create_task(_slow_runner())] + abort_event = asyncio.Event() # deliberately NOT set - async def raising_send(**_): - raise DingTalkApiError( - "throttled", code="Throttling.Api", retryable=True, + async def _external_cancel(): + await asyncio.sleep(0.05) + for t in plugin._runner_tasks: + t.cancel() + + asyncio.create_task(_external_cancel()) + + with pytest.raises(RuntimeError, match="concurrent stop/restart"): + await asyncio.wait_for( + plugin._wait_until_done(abort_event), timeout=2.0, ) - with patch( - "flocks.channel.builtin.dingtalk.send_message_app", - new=raising_send, - ): - result = await plugin.send_text(ctx) + @pytest.mark.asyncio + async def test_wait_until_done_silent_when_cancel_after_abort(self): + """When abort fires *first* and runners are cancelled afterwards + (the normal shutdown path), no exception should be raised — the + cancellation is expected and the gateway is already breaking out. + """ + from flocks.channel.builtin.dingtalk import DingTalkChannel - assert result.success is False - assert result.retryable is True - assert "throttled" in (result.error or "") + plugin = DingTalkChannel() + + async def _slow_runner(): + await asyncio.sleep(60) + + plugin._runner_tasks = [asyncio.create_task(_slow_runner())] + abort_event = asyncio.Event() + + async def _abort_then_cancel(): + await asyncio.sleep(0.05) + abort_event.set() + await asyncio.sleep(0) # let _wait_until_done observe abort + for t in plugin._runner_tasks: + t.cancel() + + asyncio.create_task(_abort_then_cancel()) + + # No exception expected — abort fired before cancel, so the + # gateway will break out of its loop normally. + await asyncio.wait_for( + plugin._wait_until_done(abort_event), timeout=2.0, + ) + + # Drain the cancelled runner + with contextlib.suppress(asyncio.CancelledError): + await plugin._runner_tasks[0] + + +# ------------------------------------------------------------------ +# Stream Mode helpers — gating + message extraction +# ------------------------------------------------------------------ + +class TestStreamHelpers: + def test_message_gate_dm_always_processes(self): + from flocks.channel.builtin.dingtalk.stream import _MessageGate + gate = _MessageGate({"requireMention": True}) + assert gate.should_process(SimpleNamespace(), "hi", is_group=False, chat_id="x") + + def test_message_gate_group_requires_mention_when_enabled(self): + from flocks.channel.builtin.dingtalk.stream import _MessageGate + gate = _MessageGate({"requireMention": True}) + msg_no_mention = SimpleNamespace(is_in_at_list=False) + msg_mention = SimpleNamespace(is_in_at_list=True) + assert not gate.should_process(msg_no_mention, "hi", is_group=True, chat_id="g") + assert gate.should_process(msg_mention, "hi", is_group=True, chat_id="g") + + def test_message_gate_free_response_chats_bypass_mention_check(self): + from flocks.channel.builtin.dingtalk.stream import _MessageGate + gate = _MessageGate({ + "requireMention": True, + "freeResponseChats": ["cidABC"], + }) + msg = SimpleNamespace(is_in_at_list=False) + assert gate.should_process(msg, "hi", is_group=True, chat_id="cidABC") + + def test_message_gate_mention_pattern_match(self): + from flocks.channel.builtin.dingtalk.stream import _MessageGate + gate = _MessageGate({ + "requireMention": True, + "mentionPatterns": ["^小马"], + }) + msg = SimpleNamespace(is_in_at_list=False) + assert gate.should_process(msg, "小马 你好", is_group=True, chat_id="g") + assert not gate.should_process(msg, "你好", is_group=True, chat_id="g") + + def test_message_gate_allowed_users_wildcard(self): + from flocks.channel.builtin.dingtalk.stream import _MessageGate + gate = _MessageGate({"allowedUsers": ["*"]}) + assert gate.is_user_allowed("anyone", "") + + def test_message_gate_allowed_users_exact_match(self): + from flocks.channel.builtin.dingtalk.stream import _MessageGate + gate = _MessageGate({"allowedUsers": ["staff_001"]}) + assert gate.is_user_allowed("u1", "staff_001") + assert not gate.is_user_allowed("u1", "staff_002") + + def test_chatbot_message_to_inbound_extracts_text(self): + from flocks.channel.builtin.dingtalk.stream import ( + chatbot_message_to_inbound, + ) + message = SimpleNamespace( + text=SimpleNamespace(content="hello world"), + conversation_id="cid_001", + conversation_type="2", # group + sender_id="u1", + sender_staff_id="staff_001", + sender_nick="Alice", + message_id="msg_1", + is_in_at_list=True, + ) + inbound = chatbot_message_to_inbound( + message, channel_id="dingtalk", account_id="default", + ) + assert inbound is not None + assert inbound.text == "hello world" + assert inbound.chat_id == "cid_001" + assert inbound.chat_type is ChatType.GROUP + assert inbound.sender_id == "staff_001" + assert inbound.sender_name == "Alice" + assert inbound.mentioned is True + assert inbound.message_id == "msg_1" + + @pytest.mark.asyncio + async def test_preflight_raises_on_invalid_credentials(self): + """4xx responses with auth-related codes must abort fast.""" + from flocks.channel.builtin.dingtalk import stream as stream_mod + + class _FakeResp: + def __init__(self): + self.status_code = 401 + self.content = b'{"code":"InvalidAuthentication","message":"bad secret"}' + self.text = self.content.decode() + self.request = None + + def json(self): + return {"code": "InvalidAuthentication", "message": "bad secret"} + + class _FakeClient: + def __init__(self, *a, **kw): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, *a, **kw): + return _FakeResp() + + with patch.object(stream_mod.httpx, "AsyncClient", _FakeClient): + with pytest.raises(stream_mod.DingTalkPermanentAuthError) as exc_info: + await stream_mod._preflight_open_connection( + client_id="bad", client_secret="bad", + ) + assert exc_info.value.http_status == 401 + assert exc_info.value.code == "InvalidAuthentication" + + @pytest.mark.asyncio + async def test_preflight_passes_on_2xx(self): + from flocks.channel.builtin.dingtalk import stream as stream_mod + + class _OkResp: + status_code = 200 + content = b'{"endpoint":"wss://x","ticket":"t"}' + text = content.decode() + request = None + + def json(self): + return {"endpoint": "wss://x", "ticket": "t"} + + class _OkClient: + def __init__(self, *a, **kw): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, *a, **kw): + return _OkResp() + + with patch.object(stream_mod.httpx, "AsyncClient", _OkClient): + await stream_mod._preflight_open_connection( + client_id="ok", client_secret="ok", + ) + + @pytest.mark.asyncio + async def test_preflight_5xx_is_transient(self): + """5xx must NOT be flagged permanent — the SDK / outer loop + should be allowed to retry.""" + from flocks.channel.builtin.dingtalk import stream as stream_mod + + class _Err5xx: + status_code = 503 + content = b"unavailable" + text = "unavailable" + request = None + + def json(self): + raise ValueError + + class _Client5xx: + def __init__(self, *a, **kw): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, *a, **kw): + return _Err5xx() + + with patch.object(stream_mod.httpx, "AsyncClient", _Client5xx): + with pytest.raises(stream_mod.httpx.HTTPStatusError): + await stream_mod._preflight_open_connection( + client_id="x", client_secret="x", + ) + + def test_chatbot_message_to_inbound_dm_uses_staff_id_as_chat_id(self): + """Regression: DM ``chat_id`` MUST be the staff_id, NOT the + ``conversation_id``. + + Production symptom (logs ``2026-04-23T112342.log``): + + POST /v1.0/robot/groupMessages/send: 错误描述: robot 不存在; + 解决方案:请确认 robotCode 是否正确 + + DingTalk delivers a ``cid…`` ``conversation_id`` for DMs as + well as groups, but only the group endpoint accepts it. Using + ``conversation_id`` for DMs makes ``resolve_target_kind`` see + the ``cid`` prefix and route through ``/groupMessages/send``, + which fails with the above error. The fix routes DMs through + ``staffId`` → ``/oToMessages/batchSend`` instead. + """ + from flocks.channel.builtin.dingtalk.config import resolve_target_kind + from flocks.channel.builtin.dingtalk.stream import ( + chatbot_message_to_inbound, + ) + message = SimpleNamespace( + text=SimpleNamespace(content="Hi"), + conversation_id="cidnrPnAQNfmP4fZCcLfRxZtv43vx736", + conversation_type="1", # DM + sender_id="$:LWCP_v1:$opaqueSenderId", + sender_staff_id="2250583914922119", + sender_nick="熊剑", + message_id="msg_dm_1", + ) + inbound = chatbot_message_to_inbound( + message, channel_id="dingtalk", account_id="default", + ) + assert inbound is not None + assert inbound.chat_type is ChatType.DIRECT + # The critical assertion: chat_id must NOT be the conversation_id + # for DMs, because that would route the reply to /groupMessages/send. + assert inbound.chat_id == "2250583914922119" + # And the resolver must treat it as a 1:1 user target. + assert resolve_target_kind(inbound.chat_id) == "user" + + def test_chatbot_message_to_inbound_group_keeps_conversation_id(self): + from flocks.channel.builtin.dingtalk.config import resolve_target_kind + from flocks.channel.builtin.dingtalk.stream import ( + chatbot_message_to_inbound, + ) + message = SimpleNamespace( + text=SimpleNamespace(content="@bot hi"), + conversation_id="cidGROUP123", + conversation_type="2", # group + sender_id="u1", + sender_staff_id="staff_001", + sender_nick="Alice", + message_id="msg_g_1", + is_in_at_list=True, + ) + inbound = chatbot_message_to_inbound( + message, channel_id="dingtalk", account_id="default", + ) + assert inbound is not None + assert inbound.chat_type is ChatType.GROUP + assert inbound.chat_id == "cidGROUP123" + assert resolve_target_kind(inbound.chat_id) == "group" + + def test_chatbot_message_to_inbound_returns_none_when_empty(self): + from flocks.channel.builtin.dingtalk.stream import ( + chatbot_message_to_inbound, + ) + message = SimpleNamespace( + text=SimpleNamespace(content=""), + conversation_id="cid_001", + conversation_type="1", + sender_id="u1", + sender_staff_id="", + sender_nick="", + message_id="msg_2", + ) + assert chatbot_message_to_inbound( + message, channel_id="dingtalk", account_id="default", + ) is None # ------------------------------------------------------------------ diff --git a/uv.lock b/uv.lock index 25064a5fb..4ade07eba 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = "==3.12.*" resolution-markers = [ "sys_platform == 'win32'", @@ -388,6 +388,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "dingtalk-stream" +version = "0.24.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "requests" }, + { name = "websockets" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/44/102dede3f371277598df6aa9725b82e3add068c729333c7a5dbc12764579/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad", size = 27813, upload-time = "2025-10-24T09:36:57.497Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -481,6 +494,7 @@ dependencies = [ { name = "click" }, { name = "croniter" }, { name = "defusedxml" }, + { name = "dingtalk-stream" }, { name = "fastapi" }, { name = "gitpython" }, { name = "google-genai" }, @@ -543,6 +557,7 @@ requires-dist = [ { name = "click", specifier = ">=8.1.7" }, { name = "croniter", specifier = ">=6.0.0" }, { name = "defusedxml", specifier = ">=0.7.1" }, + { name = "dingtalk-stream", specifier = ">=0.20" }, { name = "fastapi", specifier = ">=0.109.0" }, { name = "gitpython", specifier = ">=3.1.41" }, { name = "google-genai", specifier = ">=0.3.0" }, From 759ecfa24f78484fb368cb2dfd91ab1fdc46d99f Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Thu, 23 Apr 2026 11:43:38 +0800 Subject: [PATCH 2/4] refactor(channel/dingtalk): unify chat_id resolution and improve startup observability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up cleanup on top of the Stream Mode rewrite: * Extract `_resolve_chat_id` / `_is_group_message` helpers and reuse them from both `chatbot_message_to_inbound` (outbound routing) and `DingTalkStreamRunner._dispatch` (inbound gating). The two paths previously disagreed: `_dispatch` used `conversation_id or sender_id` unconditionally, while the inbound builder branched on DM vs group. This was harmless today but a latent bug — an admin whitelisting a chat in `free_response_chats` could see gating accept a DM and the reply still get routed somewhere else. * Drop the unused `CallbackMessage` import (and its `None` shim in the `ImportError` fallback). * Bump `dingtalk.stream.starting` from DEBUG to INFO and add a matching `dingtalk.stream.stopped` INFO when the SDK's `start()` returns cleanly. Channel startup is a low-frequency, high-signal event; losing it in production logs makes it impossible to tell whether the SDK ever attempted to open a websocket. Tests: 4 new unit tests in `tests/channel/test_dingtalk.py` covering the helper's DM/group/fallback behaviour and a regression that pins `_dispatch` and `chatbot_message_to_inbound` to the same `chat_id` for any given message. Full suite: 66 passed. Made-with: Cursor --- flocks/channel/builtin/dingtalk/stream.py | 80 ++++++++++++++------- tests/channel/test_dingtalk.py | 86 +++++++++++++++++++++++ 2 files changed, 141 insertions(+), 25 deletions(-) diff --git a/flocks/channel/builtin/dingtalk/stream.py b/flocks/channel/builtin/dingtalk/stream.py index 89682eff4..8a5415602 100644 --- a/flocks/channel/builtin/dingtalk/stream.py +++ b/flocks/channel/builtin/dingtalk/stream.py @@ -46,14 +46,13 @@ try: import dingtalk_stream from dingtalk_stream import ChatbotMessage - from dingtalk_stream.frames import AckMessage, CallbackMessage + from dingtalk_stream.frames import AckMessage DINGTALK_STREAM_AVAILABLE = True except ImportError: # pragma: no cover - exercised when the optional dep is missing DINGTALK_STREAM_AVAILABLE = False dingtalk_stream = None # type: ignore[assignment] ChatbotMessage = None # type: ignore[assignment] - CallbackMessage = None # type: ignore[assignment] AckMessage = type( "AckMessage", (), @@ -367,6 +366,39 @@ def should_process( # --------------------------------------------------------------------------- +def _is_group_message(message: Any) -> bool: + conversation_type = str(getattr(message, "conversation_type", "1") or "1") + return conversation_type == _CONVERSATION_TYPE_GROUP + + +def _resolve_chat_id(message: Any, *, is_group: bool) -> str: + """Compute the routing ``chat_id`` for an inbound DingTalk message. + + DingTalk delivers a ``conversation_id`` (``cid…``) for *both* DMs + and group chats — but only group chats can be replied to via + ``/v1.0/robot/groupMessages/send``. DMs MUST be sent to the user's + ``staffId`` via ``/v1.0/robot/oToMessages/batchSend``; routing a DM + through the group endpoint fails with ``robot 不存在``. + + :func:`flocks.channel.builtin.dingtalk.config.resolve_target_kind` + infers the outbound route from the ``chat_id`` prefix + (``cid`` → group, otherwise → user), so picking the right id here is + what keeps outbound replies routed correctly. + + Both :func:`chatbot_message_to_inbound` (which builds the + ``InboundMessage`` the gateway dispatches) and + :meth:`DingTalkStreamRunner._dispatch` (which gates the message + against ``free_response_chats`` etc.) call this helper so the two + code paths can never disagree on what counts as "the chat". + """ + conversation_id = str(getattr(message, "conversation_id", "") or "") + sender_id = str(getattr(message, "sender_id", "") or "") + sender_staff_id = str(getattr(message, "sender_staff_id", "") or "") + if is_group: + return conversation_id or sender_staff_id or sender_id + return sender_staff_id or sender_id or conversation_id + + def chatbot_message_to_inbound( message: Any, *, @@ -383,27 +415,12 @@ def chatbot_message_to_inbound( if not text and not media_url: return None - conversation_id = str(getattr(message, "conversation_id", "") or "") - conversation_type = str(getattr(message, "conversation_type", "1") or "1") - is_group = conversation_type == _CONVERSATION_TYPE_GROUP - + is_group = _is_group_message(message) sender_id = str(getattr(message, "sender_id", "") or "") sender_staff_id = str(getattr(message, "sender_staff_id", "") or "") sender_nick = str(getattr(message, "sender_nick", "") or sender_id) - # DingTalk delivers a ``conversation_id`` (cid…) for *both* DMs and - # group chats — but only group chats can be replied to via - # ``/v1.0/robot/groupMessages/send``. DMs MUST be sent to the - # user's ``staffId`` via ``/v1.0/robot/oToMessages/batchSend``; - # routing a DM through the group endpoint fails with - # ``robot 不存在``. ``resolve_target_kind`` infers the route from - # the ``chat_id`` prefix (``cid`` → group, otherwise → user), so - # picking the right id here is what keeps outbound replies routed - # correctly. - if is_group: - chat_id = conversation_id or sender_staff_id or sender_id - else: - chat_id = sender_staff_id or sender_id or conversation_id + chat_id = _resolve_chat_id(message, is_group=is_group) chat_type = ChatType.GROUP if is_group else ChatType.DIRECT mentioned = bool(getattr(message, "is_in_at_list", False)) if is_group else False @@ -549,8 +566,12 @@ async def _run_with_reconnect(self) -> None: "account": self.account_id, "error": str(exc), }) + # INFO (not DEBUG): channel startup is a low-frequency, + # high-signal event — losing it in production logs makes it + # essentially impossible to tell whether the SDK ever even + # tried to open a websocket. try: - log.debug("dingtalk.stream.starting", {"account": self.account_id}) + log.info("dingtalk.stream.starting", {"account": self.account_id}) await self._stream_client.start() except asyncio.CancelledError: return @@ -560,6 +581,18 @@ async def _run_with_reconnect(self) -> None: log.warning("dingtalk.stream.error", { "account": self.account_id, "error": str(exc), }) + else: + # The SDK's ``start()`` returned *without* raising. In + # practice this only happens when the long-running + # websocket loop exited cleanly (e.g. server-initiated + # close, token refresh edge case). Log it so operators + # can correlate the upcoming reconnect with the silent + # close, instead of seeing a bare "reconnecting" line. + if self._running: + log.info("dingtalk.stream.stopped", { + "account": self.account_id, + "hint": "SDK start() returned without exception; will reconnect", + }) if not self._running: return @@ -624,11 +657,8 @@ async def _dispatch(self, chatbot_msg: Any) -> None: }) return - conversation_type = str( - getattr(chatbot_msg, "conversation_type", "1") or "1" - ) - is_group = conversation_type == _CONVERSATION_TYPE_GROUP - chat_id = str(getattr(chatbot_msg, "conversation_id", "") or "") or sender_id + is_group = _is_group_message(chatbot_msg) + chat_id = _resolve_chat_id(chatbot_msg, is_group=is_group) if not self._gate.should_process(chatbot_msg, text, is_group, chat_id): log.debug("dingtalk.stream.gate_dropped", { diff --git a/tests/channel/test_dingtalk.py b/tests/channel/test_dingtalk.py index 34a20cc9b..b708fd070 100644 --- a/tests/channel/test_dingtalk.py +++ b/tests/channel/test_dingtalk.py @@ -820,6 +820,92 @@ def test_chatbot_message_to_inbound_group_keeps_conversation_id(self): assert inbound.chat_id == "cidGROUP123" assert resolve_target_kind(inbound.chat_id) == "group" + def test_resolve_chat_id_dm_prefers_staff_id(self): + from flocks.channel.builtin.dingtalk.stream import _resolve_chat_id + msg = SimpleNamespace( + conversation_id="cidABC", + sender_id="$:LWCP_v1:$opaque", + sender_staff_id="2250583914922119", + ) + assert _resolve_chat_id(msg, is_group=False) == "2250583914922119" + + def test_resolve_chat_id_dm_falls_back_when_no_staff_id(self): + """When DingTalk omits ``sender_staff_id`` (rare but possible + for external/unverified users) we fall back to ``sender_id`` — + and only as a last resort to ``conversation_id``. + """ + from flocks.channel.builtin.dingtalk.stream import _resolve_chat_id + msg = SimpleNamespace( + conversation_id="cidABC", + sender_id="user_42", + sender_staff_id="", + ) + assert _resolve_chat_id(msg, is_group=False) == "user_42" + + msg2 = SimpleNamespace( + conversation_id="cidABC", + sender_id="", + sender_staff_id="", + ) + assert _resolve_chat_id(msg2, is_group=False) == "cidABC" + + def test_resolve_chat_id_group_uses_conversation_id(self): + from flocks.channel.builtin.dingtalk.stream import _resolve_chat_id + msg = SimpleNamespace( + conversation_id="cidGROUP123", + sender_id="u1", + sender_staff_id="staff_001", + ) + assert _resolve_chat_id(msg, is_group=True) == "cidGROUP123" + + def test_dispatch_and_inbound_agree_on_chat_id(self): + """Regression: ``_dispatch`` (used for gating) and + ``chatbot_message_to_inbound`` (used for routing) MUST agree on + the ``chat_id`` for the same message — otherwise an admin who + whitelists a chat in ``free_response_chats`` could see gating + accept the message but the reply land in a different chat (or + worse, fail with ``robot 不存在``). + """ + from flocks.channel.builtin.dingtalk.stream import ( + _is_group_message, + _resolve_chat_id, + chatbot_message_to_inbound, + ) + dm = SimpleNamespace( + text=SimpleNamespace(content="hello"), + conversation_id="cidDM", + conversation_type="1", + sender_id="u_dm", + sender_staff_id="staff_dm", + sender_nick="Bob", + message_id="m_dm", + ) + inbound_dm = chatbot_message_to_inbound( + dm, channel_id="dingtalk", account_id="default", + ) + assert inbound_dm is not None + assert inbound_dm.chat_id == _resolve_chat_id( + dm, is_group=_is_group_message(dm), + ) + + group = SimpleNamespace( + text=SimpleNamespace(content="@bot hi"), + conversation_id="cidGRP", + conversation_type="2", + sender_id="u_g", + sender_staff_id="staff_g", + sender_nick="Carol", + message_id="m_g", + is_in_at_list=True, + ) + inbound_group = chatbot_message_to_inbound( + group, channel_id="dingtalk", account_id="default", + ) + assert inbound_group is not None + assert inbound_group.chat_id == _resolve_chat_id( + group, is_group=_is_group_message(group), + ) + def test_chatbot_message_to_inbound_returns_none_when_empty(self): from flocks.channel.builtin.dingtalk.stream import ( chatbot_message_to_inbound, From 5e965e3b157e58e5fc17acb627c7f6ee009f3761 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Thu, 23 Apr 2026 20:14:28 +0800 Subject: [PATCH 3/4] chore(webui/dingtalk): drop obsolete gatewayToken config field The "Gateway Token" field was a leftover from the legacy Node.js OpenClaw connector (removed in 5812a31). The new pure-Python Stream Mode channel talks to wss-open.dingtalk.com directly using only clientId / clientSecret, so the backend never reads gatewayToken. Keeping it in the UI only misled users into thinking an extra credential was required. - Remove gatewayToken from DingTalkChannelConfig interface and form - Remove dingtalk.gatewayToken / gatewayTokenHint from zh-CN and en-US channel locales Existing flocks.json entries with this field are silently ignored by ChannelConfig (extra="allow") and will be cleaned up on next save. Made-with: Cursor --- webui/src/locales/en-US/channel.json | 2 -- webui/src/locales/zh-CN/channel.json | 2 -- webui/src/pages/Channel/index.tsx | 8 -------- 3 files changed, 12 deletions(-) diff --git a/webui/src/locales/en-US/channel.json b/webui/src/locales/en-US/channel.json index 7e8718e83..5a02ae3a5 100644 --- a/webui/src/locales/en-US/channel.json +++ b/webui/src/locales/en-US/channel.json @@ -173,8 +173,6 @@ "downloadGuide": "Download Configuration Guide", "clientIdHint": "DingTalk app Client ID (i.e. AppKey)", "clientSecretHint": "DingTalk app Client Secret (i.e. AppSecret)", - "gatewayToken": "Gateway Token", - "gatewayTokenHint": "OpenClaw gateway authentication token (optional)", "optional": "Optional", "behavior": "Message Behavior", "behaviorDesc": "Configure message routing and user allowlist.", diff --git a/webui/src/locales/zh-CN/channel.json b/webui/src/locales/zh-CN/channel.json index 1979cce9d..a7e25b18a 100644 --- a/webui/src/locales/zh-CN/channel.json +++ b/webui/src/locales/zh-CN/channel.json @@ -173,8 +173,6 @@ "downloadGuide": "下载配置指引", "clientIdHint": "钉钉应用的 Client ID(即 AppKey)", "clientSecretHint": "钉钉应用的 Client Secret(即 AppSecret)", - "gatewayToken": "网关令牌", - "gatewayTokenHint": "OpenClaw 网关鉴权令牌(选填)", "optional": "选填", "behavior": "消息行为", "behaviorDesc": "配置消息路由和用户白名单。", diff --git a/webui/src/pages/Channel/index.tsx b/webui/src/pages/Channel/index.tsx index 6a18f45c6..ef2469692 100644 --- a/webui/src/pages/Channel/index.tsx +++ b/webui/src/pages/Channel/index.tsx @@ -109,7 +109,6 @@ interface DingTalkChannelConfig { clientId?: string; clientSecret?: string; defaultAgent?: string; - gatewayToken?: string; debug?: boolean; allowFrom?: string[]; } @@ -1126,13 +1125,6 @@ function DingTalkPanel({ config, onChange }: DingTalkPanelProps) { placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxx" /> - - set('gatewayToken', v || undefined)} - placeholder={t('dingtalk.optional')} - /> -
From bae0dcb4642968d025bf54674652bb11749e3247 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Thu, 23 Apr 2026 20:40:35 +0800 Subject: [PATCH 4/4] fix(channel/dingtalk): harden stream runner against silent stalls and inbound floods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production triage of the DingTalk stream channel surfaced two P0 risks that would only fire under specific real-world conditions: R1 — Silent stall (gateway rate-limit / region block / suspended app) The dingtalk-stream SDK's start() swallows every error from open_connection() and returns ``None`` instead of raising. When the gateway accepts our ticket but immediately tears down the websocket (e.g. risk-control trips, app suspended), the runner burns one preflight + one reconnect cycle every ~2-60s forever without surfacing any permanent error — the channel looks "connected" while delivering zero messages. R2 — Unbounded inbound concurrency (group bursts) ChatbotHandler.process() previously dispatched each frame via asyncio.create_task() with no cap. A noisy group flooding 100+ messages would spawn 100+ concurrent on_message coroutines, each holding an LLM round-trip — easy path to OOM, rate-limit storms on the upstream model API, and starvation of other accounts sharing the event loop. Fixes * R1 — stall detection Track inbound delivery + per-run duration around every start() call. Treat "clean return + run < 30s + zero messages received" as a stall signal. After 5 consecutive short clean returns, raise the new DingTalkStreamStallError so the channel layer drops the account from the reconnect schedule (same path used today for permanent auth failures). Counter resets on the first healthy run so transient flakes can't accumulate strikes. * R3 — bounded inbound dispatch Replace the ad-hoc create_task fan-out with a bounded asyncio.Queue (default 256) drained by a fixed worker pool (default 8). SDK process() uses put_nowait so the heartbeat ack path stays non-blocking; QueueFull drops the new message and bumps a counter for telemetry. Both sizes are tunable per account via dispatchWorkers / dispatchQueueSize. Counting _messages_received at enqueue time (before back-pressure) preserves the R1 stall signal even when shedding load — "gateway is pushing us things we can't handle" is NOT the same failure mode as "gateway has gone silent". Refactor * Introduce DingTalkPermanentError as the common base for non-retryable runner failures; DingTalkPermanentAuthError and the new DingTalkStreamStallError both inherit from it. channel.py's _classify_and_raise switches to the base class so future permanent error types are handled automatically. Reliability bonus (P1.R4) * Bound the synchronous client.close() teardown with a 5s asyncio.wait_for so a hung socket can't stall a channel restart indefinitely. Times out → log warning, proceed with cancel. Tests * test_stall_detection_escalates_after_consecutive_short_clean_returns * test_stall_counter_resets_after_inbound_message * test_dispatch_queue_drops_overflow_without_blocking * test_enqueue_before_pool_started_does_not_crash * test_permanent_error_hierarchy 71/71 passing in tests/channel/test_dingtalk.py. Made-with: Cursor --- flocks/channel/builtin/dingtalk/channel.py | 18 +- flocks/channel/builtin/dingtalk/stream.py | 309 +++++++++++++++++++-- tests/channel/test_dingtalk.py | 273 ++++++++++++++++++ 3 files changed, 565 insertions(+), 35 deletions(-) diff --git a/flocks/channel/builtin/dingtalk/channel.py b/flocks/channel/builtin/dingtalk/channel.py index 75b4148dc..8a9274ebf 100644 --- a/flocks/channel/builtin/dingtalk/channel.py +++ b/flocks/channel/builtin/dingtalk/channel.py @@ -157,10 +157,14 @@ async def start( Blocks until *abort_event* fires or every runner task exits. - * Permanent auth failures (4xx from the gateway) are logged per - account and that account is dropped from the schedule, but - they do **not** propagate to the gateway — retrying with the - same bad credentials would be pointless. + * Permanent failures (subclasses of ``DingTalkPermanentError``) + are logged per account and that account is dropped from the + schedule, but they do **not** propagate to the gateway — + retrying would be pointless. Today this covers: + - ``DingTalkPermanentAuthError`` (bad credentials / app + revoked / Stream Mode subscription disabled) + - ``DingTalkStreamStallError`` (SDK keeps returning instantly + with no inbound traffic — gateway is silently blocking us) * Other (transient) runner exceptions DO propagate so the gateway's exponential-backoff reconnect policy can take over. """ @@ -240,7 +244,7 @@ async def _wait_until_done(self, abort_event: Optional[asyncio.Event]) -> None: permanently disconnected. """ from flocks.channel.builtin.dingtalk.stream import ( - DingTalkPermanentAuthError, + DingTalkPermanentError, ) if abort_event is None: @@ -249,7 +253,7 @@ async def _wait_until_done(self, abort_event: Optional[asyncio.Event]) -> None: ) self._classify_and_raise( results, - permanent_exc_type=DingTalkPermanentAuthError, + permanent_exc_type=DingTalkPermanentError, abort_set=False, ) return @@ -280,7 +284,7 @@ async def _wait_until_done(self, abort_event: Optional[asyncio.Event]) -> None: results = runners_waiter.result() self._classify_and_raise( results, - permanent_exc_type=DingTalkPermanentAuthError, + permanent_exc_type=DingTalkPermanentError, abort_set=abort_event.is_set(), ) diff --git a/flocks/channel/builtin/dingtalk/stream.py b/flocks/channel/builtin/dingtalk/stream.py index 8a5415602..bcce9859a 100644 --- a/flocks/channel/builtin/dingtalk/stream.py +++ b/flocks/channel/builtin/dingtalk/stream.py @@ -33,6 +33,7 @@ import json import platform import re +import time import uuid from typing import Any, Awaitable, Callable, Optional @@ -85,8 +86,39 @@ "unauthorizedclient", }) +# ── Stall detection (R1) ──────────────────────────────────────────── +# The dingtalk-stream SDK's ``start()`` swallows every error from +# ``open_connection`` and silently returns when, e.g., the gateway +# accepts the ticket but the WebSocket is closed immediately by the +# server (rate limit, app suspended, region block, …). Without +# escalation the runner would burn ~1 reconnect/min forever without +# ever surfacing the problem. We treat N consecutive "clean returns +# in < THRESHOLD seconds, with zero inbound messages" as a stall and +# raise :class:`DingTalkStreamStallError` so the channel layer pauses +# reconnects on this account. +_STALL_RUN_DURATION_THRESHOLD_SECONDS = 30.0 +_STALL_MAX_CONSECUTIVE_SHORT_RUNS = 5 + +# ── Inbound back-pressure (R3) ────────────────────────────────────── +# SDK's ``ChatbotHandler.process()`` MUST ack quickly so heartbeats +# keep flowing — we cannot block on a semaphore there. Instead we +# enqueue the parsed message into a bounded queue drained by a fixed +# worker pool; queue overflow drops the *new* message and logs a +# warning so operators can size the queue / workers from telemetry. +_DEFAULT_DISPATCH_WORKERS = 8 +_DEFAULT_DISPATCH_QUEUE_SIZE = 256 + + +class DingTalkPermanentError(RuntimeError): + """Base for permanent (non-retryable) DingTalk runner failures. + + Anything inheriting from this signals the channel layer to drop + the offending account from the reconnect schedule — retrying + with the same configuration will not succeed. + """ + -class DingTalkPermanentAuthError(RuntimeError): +class DingTalkPermanentAuthError(DingTalkPermanentError): """Raised when the DingTalk gateway rejects the credentials with a 4xx status that retrying cannot fix (bad clientId/clientSecret, app revoked, Stream Mode subscription not enabled, …). @@ -104,6 +136,26 @@ def __init__( self.http_status = http_status +class DingTalkStreamStallError(DingTalkPermanentError): + """Raised when the SDK keeps returning immediately from ``start()`` + without ever delivering a message — strong indicator that the + gateway is silently rejecting our connection (rate limit, region + block, app suspended, …). See ``_STALL_*`` constants for the + detection thresholds. + """ + + def __init__( + self, + message: str, + *, + consecutive_short_runs: int, + last_run_duration: float, + ) -> None: + super().__init__(message) + self.consecutive_short_runs = consecutive_short_runs + self.last_run_duration = last_run_duration + + OnMessage = Callable[[InboundMessage], Awaitable[None]] @@ -480,11 +532,44 @@ def __init__( self._stream_client: Any = None self._stream_task: Optional[asyncio.Task] = None self._running = False - self._dispatch_tasks: set[asyncio.Task] = set() - # Set by ``_run_with_reconnect`` when the gateway rejects the - # credentials with a 4xx — surfaced from ``run()`` after shutdown - # so the channel layer can stop retrying that account. - self._permanent_error: Optional[DingTalkPermanentAuthError] = None + + # ── R3: bounded inbound dispatch queue + worker pool ───────── + # Both knobs are tunable per-account so noisy tenants can lift + # their own caps without affecting siblings. + self._dispatch_workers = max( + 1, + int( + account_config.get("dispatchWorkers") + or account_config.get("dispatch_workers") + or _DEFAULT_DISPATCH_WORKERS + ), + ) + self._dispatch_queue_size = max( + self._dispatch_workers, + int( + account_config.get("dispatchQueueSize") + or account_config.get("dispatch_queue_size") + or _DEFAULT_DISPATCH_QUEUE_SIZE + ), + ) + self._dispatch_queue: Optional[asyncio.Queue[Any]] = None + self._worker_tasks: list[asyncio.Task] = [] + self._dropped_messages = 0 + + # ── R1: stall detection state ──────────────────────────────── + # ``_messages_received`` is incremented by ``_enqueue_dispatch`` + # so it reflects what the SDK actually delivered (not what + # workers eventually processed) — that's the right signal for + # "is the gateway still pushing us anything?". + self._messages_received = 0 + self._consecutive_short_runs = 0 + + # Set by ``_run_with_reconnect`` when the runner hits a permanent + # failure (bad credentials → :class:`DingTalkPermanentAuthError`, + # silent stall → :class:`DingTalkStreamStallError`). Surfaced + # from ``run()`` after shutdown so the channel layer can stop + # retrying that account. + self._permanent_error: Optional[DingTalkPermanentError] = None def is_configured(self) -> bool: return bool(self.client_id and self.client_secret) @@ -510,6 +595,23 @@ async def run(self, abort_event: Optional[asyncio.Event] = None) -> None: dingtalk_stream.ChatbotMessage.TOPIC, handler ) + # R3: spin up the dispatch queue + worker pool BEFORE the SDK + # starts pushing messages, so the very first inbound frame has + # somewhere to go. + self._dispatch_queue = asyncio.Queue(maxsize=self._dispatch_queue_size) + self._worker_tasks = [ + asyncio.create_task( + self._dispatch_worker(idx), + name=f"dingtalk-dispatch-{self.account_id}-{idx}", + ) + for idx in range(self._dispatch_workers) + ] + log.info("dingtalk.stream.dispatch_pool_started", { + "account": self.account_id, + "workers": self._dispatch_workers, + "queue_size": self._dispatch_queue_size, + }) + self._running = True self._stream_task = asyncio.create_task(self._run_with_reconnect()) try: @@ -520,8 +622,10 @@ async def run(self, abort_event: Optional[asyncio.Event] = None) -> None: finally: await self._shutdown() - # Surface permanent auth failure AFTER cleanup so the channel - # can drop this account from the reconnect schedule. + # Surface permanent failures AFTER cleanup so the channel can + # drop this account from the reconnect schedule. Covers both + # bad credentials (DingTalkPermanentAuthError) and silent + # gateway rejection (DingTalkStreamStallError). if self._permanent_error is not None: raise self._permanent_error @@ -566,6 +670,17 @@ async def _run_with_reconnect(self) -> None: "account": self.account_id, "error": str(exc), }) + # ── R1: stall accounting ───────────────────────────────── + # Snapshot inbound counters around the SDK's ``start()`` so + # we can later tell apart: + # (a) healthy long-lived connection (duration ≫ threshold) + # (b) connection torn down by an exception → backoff path + # (c) silent gateway rejection (clean return, < threshold, + # zero messages delivered) → escalate after N in a row + run_started_at = time.monotonic() + messages_at_start = self._messages_received + clean_return = False + # INFO (not DEBUG): channel startup is a low-frequency, # high-signal event — losing it in production logs makes it # essentially impossible to tell whether the SDK ever even @@ -573,6 +688,7 @@ async def _run_with_reconnect(self) -> None: try: log.info("dingtalk.stream.starting", {"account": self.account_id}) await self._stream_client.start() + clean_return = True except asyncio.CancelledError: return except Exception as exc: @@ -581,18 +697,75 @@ async def _run_with_reconnect(self) -> None: log.warning("dingtalk.stream.error", { "account": self.account_id, "error": str(exc), }) + + run_duration = time.monotonic() - run_started_at + messages_during_run = self._messages_received - messages_at_start + + # Only "clean return + short + zero messages" counts as a + # stall signal. An exception (case b) is a normal recovery + # path — DingTalk tears down idle sockets, the network + # flakes, etc.; counting those as stalls would falsely + # disable healthy accounts after a few WiFi blips. + if ( + clean_return + and self._running + and run_duration < _STALL_RUN_DURATION_THRESHOLD_SECONDS + and messages_during_run == 0 + ): + self._consecutive_short_runs += 1 + log.warning("dingtalk.stream.short_run_detected", { + "account": self.account_id, + "duration_seconds": round(run_duration, 2), + "consecutive_short_runs": self._consecutive_short_runs, + "max_allowed": _STALL_MAX_CONSECUTIVE_SHORT_RUNS, + "hint": ( + "SDK start() returned cleanly in <" + f"{_STALL_RUN_DURATION_THRESHOLD_SECONDS:.0f}s " + "without delivering any messages — gateway may " + "be silently rejecting the connection" + ), + }) + if self._consecutive_short_runs >= _STALL_MAX_CONSECUTIVE_SHORT_RUNS: + err = DingTalkStreamStallError( + f"DingTalk stream returned immediately " + f"{self._consecutive_short_runs} times in a row " + f"without receiving any messages " + f"(last run: {run_duration:.2f}s); pausing " + f"reconnects on this account", + consecutive_short_runs=self._consecutive_short_runs, + last_run_duration=run_duration, + ) + log.error("dingtalk.stream.stall_detected", { + "account": self.account_id, + "consecutive_short_runs": self._consecutive_short_runs, + "last_run_duration": round(run_duration, 2), + }) + self._permanent_error = err + return else: - # The SDK's ``start()`` returned *without* raising. In - # practice this only happens when the long-running - # websocket loop exited cleanly (e.g. server-initiated - # close, token refresh edge case). Log it so operators - # can correlate the upcoming reconnect with the silent - # close, instead of seeing a bare "reconnecting" line. - if self._running: - log.info("dingtalk.stream.stopped", { + # Healthy connection, or a non-clean exit — reset the + # counter so a single bad streak doesn't accumulate + # across hours of normal operation. + if self._consecutive_short_runs: + log.info("dingtalk.stream.short_run_counter_reset", { "account": self.account_id, - "hint": "SDK start() returned without exception; will reconnect", + "previous_count": self._consecutive_short_runs, + "duration_seconds": round(run_duration, 2), + "messages_during_run": messages_during_run, }) + self._consecutive_short_runs = 0 + + if clean_return and self._running: + # Log every clean return (not just stall candidates) so + # operators can correlate the upcoming reconnect with + # the silent close, instead of seeing a bare + # "reconnecting" line. + log.info("dingtalk.stream.stopped", { + "account": self.account_id, + "duration_seconds": round(run_duration, 2), + "messages_during_run": messages_during_run, + "hint": "SDK start() returned without exception; will reconnect", + }) if not self._running: return @@ -623,7 +796,17 @@ async def _shutdown(self) -> None: if self._stream_task: if client is not None and hasattr(client, "close"): try: - await asyncio.to_thread(client.close) + # ``client.close()`` is a synchronous teardown that + # may issue a blocking HTTP call; bound it so a + # hanging socket can't stall the channel restart. + await asyncio.wait_for( + asyncio.to_thread(client.close), + timeout=5.0, + ) + except (asyncio.CancelledError, asyncio.TimeoutError): + log.warning("dingtalk.stream.client_close_timeout", { + "account": self.account_id, + }) except Exception: pass self._stream_task.cancel() @@ -633,11 +816,16 @@ async def _shutdown(self) -> None: pass self._stream_task = None - for task in list(self._dispatch_tasks): - task.cancel() - if self._dispatch_tasks: - await asyncio.gather(*self._dispatch_tasks, return_exceptions=True) - self._dispatch_tasks.clear() + # Tear down the dispatch pool (R3): cancel workers, then drain. + # Cancelling first guarantees workers wake up out of ``queue.get()`` + # even if no producer is around to push a sentinel. + for task in self._worker_tasks: + if not task.done(): + task.cancel() + if self._worker_tasks: + await asyncio.gather(*self._worker_tasks, return_exceptions=True) + self._worker_tasks = [] + self._dispatch_queue = None self._stream_client = None @@ -680,10 +868,73 @@ async def _dispatch(self, chatbot_msg: Any) -> None: "account": self.account_id, }) - def _spawn_dispatch(self, chatbot_msg: Any) -> None: - task = asyncio.create_task(self._dispatch(chatbot_msg)) - self._dispatch_tasks.add(task) - task.add_done_callback(self._dispatch_tasks.discard) + async def _dispatch_worker(self, idx: int) -> None: + """Drain :attr:`_dispatch_queue` until cancelled. + + One worker = one in-flight ``on_message`` call at a time, so + ``_dispatch_workers`` directly caps inbound concurrency per + account. Errors inside ``_dispatch`` are already logged there; + we only catch here to keep the worker alive across them. + """ + queue = self._dispatch_queue + if queue is None: + return + while True: + try: + chatbot_msg = await queue.get() + except asyncio.CancelledError: + return + try: + await self._dispatch(chatbot_msg) + except asyncio.CancelledError: + return + except Exception: + log.exception("dingtalk.stream.worker_error", { + "account": self.account_id, + "worker": idx, + }) + finally: + try: + queue.task_done() + except ValueError: + pass + + def _enqueue_dispatch(self, chatbot_msg: Any) -> None: + """Hand off *chatbot_msg* to the worker pool. + + Called from the SDK's ``ChatbotHandler.process()`` which MUST + return its ack quickly — so we use ``put_nowait`` and drop the + new message on overflow rather than blocking the heartbeat + path. Burst load (group floods) sheds gracefully instead of + spinning unbounded background tasks (R3). + """ + # ``_messages_received`` powers the stall-detection counter + # (R1); count what the SDK actually delivered, even if we end + # up shedding the message due to back-pressure. + self._messages_received += 1 + + queue = self._dispatch_queue + if queue is None: + log.warning("dingtalk.stream.dispatch_queue_missing", { + "account": self.account_id, + "hint": "message received before runner started or after shutdown", + }) + return + try: + queue.put_nowait(chatbot_msg) + except asyncio.QueueFull: + self._dropped_messages += 1 + log.warning("dingtalk.stream.dispatch_queue_full", { + "account": self.account_id, + "queue_size": self._dispatch_queue_size, + "workers": self._dispatch_workers, + "dropped_total": self._dropped_messages, + "hint": ( + "increase dispatchWorkers / dispatchQueueSize for " + "this account, or investigate slow on_message " + "handler" + ), + }) # --------------------------------------------------------------------------- @@ -729,7 +980,7 @@ async def process(self, message: Any): # type: ignore[override] if data.get("isInAtList"): chatbot_msg.is_in_at_list = True - self._runner._spawn_dispatch(chatbot_msg) + self._runner._enqueue_dispatch(chatbot_msg) except Exception: log.exception("dingtalk.stream.handler_error") return AckMessage.STATUS_SYSTEM_EXCEPTION, "error" @@ -739,6 +990,8 @@ async def process(self, message: Any): # type: ignore[override] __all__ = [ "DINGTALK_STREAM_AVAILABLE", "DingTalkPermanentAuthError", + "DingTalkPermanentError", "DingTalkStreamRunner", + "DingTalkStreamStallError", "chatbot_message_to_inbound", ] diff --git a/tests/channel/test_dingtalk.py b/tests/channel/test_dingtalk.py index b708fd070..f6d0b1c44 100644 --- a/tests/channel/test_dingtalk.py +++ b/tests/channel/test_dingtalk.py @@ -924,6 +924,279 @@ def test_chatbot_message_to_inbound_returns_none_when_empty(self): ) is None +# ------------------------------------------------------------------ +# Resilience regressions: R1 (silent stall) + R3 (back-pressure) +# ------------------------------------------------------------------ + +@pytest.mark.skipif( + importlib.util.find_spec("dingtalk_stream") is None, + reason="dingtalk-stream SDK not installed", +) +class TestStreamRunnerResilience: + """End-to-end tests for the runner's failure-mode safeguards. + + These exercise the loop in :meth:`DingTalkStreamRunner._run_with_reconnect` + by patching :meth:`DingTalkStreamClient.start` so we can drive + ``clean return`` / ``raised`` / ``slow`` scenarios deterministically + without touching the network. The pre-flight HTTP check is also + short-circuited. + """ + + def _make_runner(self, *, on_message=None, account_overrides=None): + from flocks.channel.builtin.dingtalk.stream import DingTalkStreamRunner + + config = { + "_account_id": "default", + "appKey": "key", + "appSecret": "secret", + } + if account_overrides: + config.update(account_overrides) + runner = DingTalkStreamRunner( + account_config=config, + on_message=on_message or AsyncMock(), + ) + return runner + + @pytest.mark.asyncio + async def test_stall_detection_escalates_after_consecutive_short_clean_returns( + self, monkeypatch, + ): + """R1 regression: SDK ``start()`` returning instantly with zero + messages, repeated N times, MUST raise + :class:`DingTalkStreamStallError` so the channel layer pauses + reconnects on this account. + + Production symptom (without this guard): the runner burns one + preflight + one reconnect cycle every ~2-60s forever, never + delivering a message and never surfacing a permanent error. + """ + from flocks.channel.builtin.dingtalk import stream as stream_mod + + # Skip pre-flight; we're testing the SDK's own behaviour. + async def _ok_preflight(**_kw): + return None + + monkeypatch.setattr( + stream_mod, "_preflight_open_connection", _ok_preflight + ) + # Collapse backoff so the test runs in milliseconds, not minutes. + monkeypatch.setattr(stream_mod, "_RECONNECT_BACKOFF", [0]) + + runner = self._make_runner() + + # Fake stream client whose ``start()`` returns immediately every + # time — the exact pathology we want to detect. + class _ImmediateStartClient: + def __init__(self, *_a, **_kw): + self.start_calls = 0 + self.websocket = None + + def register_callback_handler(self, *_a, **_kw): + pass + + async def start(self): + self.start_calls += 1 + + def close(self): + pass + + monkeypatch.setattr( + stream_mod.dingtalk_stream, "DingTalkStreamClient", + _ImmediateStartClient, + ) + + with pytest.raises(stream_mod.DingTalkStreamStallError) as exc_info: + await asyncio.wait_for(runner.run(), timeout=2.0) + + # Threshold is 5 — confirm we escalate exactly at the boundary + # rather than after some larger arbitrary number of retries. + assert exc_info.value.consecutive_short_runs == 5 + # And it MUST be a subclass of DingTalkPermanentError so + # channel.py's ``_classify_and_raise`` will swallow it (no + # retry) instead of re-raising for the gateway. + assert isinstance(exc_info.value, stream_mod.DingTalkPermanentError) + + @pytest.mark.asyncio + async def test_stall_counter_resets_after_inbound_message(self, monkeypatch): + """A single healthy run (with messages delivered) MUST reset + the short-run counter — otherwise a busy account that + occasionally has a < 30s reconnect window would slowly + accumulate strikes and eventually be killed wrongly. + """ + from flocks.channel.builtin.dingtalk import stream as stream_mod + + async def _ok_preflight(**_kw): + return None + + monkeypatch.setattr( + stream_mod, "_preflight_open_connection", _ok_preflight + ) + monkeypatch.setattr(stream_mod, "_RECONNECT_BACKOFF", [0]) + + runner = self._make_runner() + # Pre-load a few "short runs" — half of the threshold. + runner._consecutive_short_runs = 3 + + call_count = {"n": 0} + + class _MixedClient: + def __init__(self, *_a, **_kw): + self.websocket = None + + def register_callback_handler(self, *_a, **_kw): + pass + + async def start(self): + call_count["n"] += 1 + if call_count["n"] == 1: + # Simulate a healthy run: deliver one message, + # then return cleanly. The runner counts + # ``_messages_received`` directly, so we bump it + # before returning to mimic an inbound frame. + runner._messages_received += 1 + return + # On the 2nd call, stop the runner so the loop exits + # without further iterations. + runner._running = False + + def close(self): + pass + + monkeypatch.setattr( + stream_mod.dingtalk_stream, "DingTalkStreamClient", + _MixedClient, + ) + + await asyncio.wait_for(runner.run(), timeout=2.0) + # Counter MUST have reset after the run that delivered a message. + assert runner._consecutive_short_runs == 0 + + @pytest.mark.asyncio + async def test_dispatch_queue_drops_overflow_without_blocking( + self, monkeypatch, + ): + """R3 regression: when the dispatch queue saturates, new + messages MUST be dropped (not blocked, not stacked as + unbounded tasks) so the SDK's ``process()`` ack path stays + non-blocking and heartbeats keep flowing. + + We size the queue + worker pool down to 1 each and pin the + single worker on a slow ``on_message`` so we can deterministically + push the queue into the QueueFull branch. + """ + from flocks.channel.builtin.dingtalk import stream as stream_mod + + # The on_message handler hangs until released — guarantees the + # single worker is busy when we enqueue the second message. + release = asyncio.Event() + first_received = asyncio.Event() + seen: list = [] + + async def _slow_on_message(msg): + seen.append(msg) + first_received.set() + await release.wait() + + runner = self._make_runner( + on_message=_slow_on_message, + account_overrides={ + "dispatchWorkers": 1, + "dispatchQueueSize": 1, + }, + ) + + # We don't want to actually open a websocket, so manually + # bootstrap just the queue + worker pool — same setup the + # ``run()`` entry point performs. This keeps the test focused + # on the back-pressure invariant. + runner._dispatch_queue = asyncio.Queue( + maxsize=runner._dispatch_queue_size, + ) + runner._worker_tasks = [ + asyncio.create_task(runner._dispatch_worker(0)) + ] + + try: + # Build three "messages" that pass the gate trivially. We + # use bare SimpleNamespace because the gate happily accepts + # any object exposing the expected attributes. + def _msg(text): + return SimpleNamespace( + text=SimpleNamespace(content=text), + conversation_id="cid_dm", + conversation_type="1", # DM → unconditionally accepted + sender_id="u1", + sender_staff_id="staff_001", + sender_nick="Alice", + message_id=f"m_{text}", + is_in_at_list=False, + ) + + runner._enqueue_dispatch(_msg("a")) # consumed by worker + await first_received.wait() + runner._enqueue_dispatch(_msg("b")) # parked in queue (size=1) + runner._enqueue_dispatch(_msg("c")) # MUST be dropped + + # _messages_received counts EVERY frame the SDK delivered — + # that's the right metric for stall detection (R1) even when + # back-pressure is sheding. + assert runner._messages_received == 3 + # The third message was dropped, not blocked, not crashed. + assert runner._dropped_messages == 1 + + release.set() + # Drain whatever the worker can finish before we cancel. + for _ in range(20): + if len(seen) >= 2: + break + await asyncio.sleep(0.01) + # Worker processed exactly the 2 that fit (a + b); c was dropped. + assert len(seen) == 2 + finally: + for task in runner._worker_tasks: + task.cancel() + await asyncio.gather( + *runner._worker_tasks, return_exceptions=True, + ) + + @pytest.mark.asyncio + async def test_enqueue_before_pool_started_does_not_crash(self): + """Calling ``_enqueue_dispatch`` before ``run()`` initialises + the queue (or after ``_shutdown()`` tore it down) must not + raise — we just log + drop. Guards against rare ordering bugs + where the SDK pushes a frame during teardown. + """ + runner = self._make_runner() + assert runner._dispatch_queue is None + + runner._enqueue_dispatch(SimpleNamespace(text="orphan")) + + # Counter ticks even though we shed — preserves the R1 stall + # signal in case a frame slips through during shutdown. + assert runner._messages_received == 1 + assert runner._dropped_messages == 0 # no QueueFull, just no queue + + def test_permanent_error_hierarchy(self): + """``DingTalkStreamStallError`` MUST inherit from + ``DingTalkPermanentError`` so :meth:`DingTalkChannel._classify_and_raise` + treats stalls the same as auth failures (drop the account from + the schedule rather than letting the gateway retry forever). + """ + from flocks.channel.builtin.dingtalk.stream import ( + DingTalkPermanentAuthError, + DingTalkPermanentError, + DingTalkStreamStallError, + ) + + assert issubclass(DingTalkPermanentAuthError, DingTalkPermanentError) + assert issubclass(DingTalkStreamStallError, DingTalkPermanentError) + # Belt-and-braces: make sure both still descend from RuntimeError + # so any generic ``except RuntimeError`` in calling code + # continues to work. + assert issubclass(DingTalkPermanentError, RuntimeError) + + # ------------------------------------------------------------------ # SessionBindingService.bind_session — used by runner.ts → /bind # ------------------------------------------------------------------