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..8a9274ebf --- /dev/null +++ b/flocks/channel/builtin/dingtalk/channel.py @@ -0,0 +1,358 @@ +""" +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 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. + """ + 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 ( + DingTalkPermanentError, + ) + + if abort_event is None: + results = await asyncio.gather( + *self._runner_tasks, return_exceptions=True, + ) + self._classify_and_raise( + results, + permanent_exc_type=DingTalkPermanentError, + 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=DingTalkPermanentError, + 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..bcce9859a --- /dev/null +++ b/flocks/channel/builtin/dingtalk/stream.py @@ -0,0 +1,997 @@ +""" +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 time +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 + + 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] + 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", +}) + +# ── 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(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, …). + """ + + 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 + + +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]] + + +# --------------------------------------------------------------------------- +# 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 _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, + *, + 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 + + 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) + + 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 + + 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 + + # ── 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) + + 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 + ) + + # 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: + if abort_event is None: + await self._stream_task + else: + await self._wait_for_abort(abort_event) + finally: + await self._shutdown() + + # 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 + + 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), + }) + + # ── 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 + # tried to open a websocket. + 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: + if not self._running: + return + 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: + # 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, + "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 + + 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: + # ``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() + try: + await asyncio.wait_for(self._stream_task, timeout=5.0) + except (asyncio.CancelledError, asyncio.TimeoutError): + pass + self._stream_task = None + + # 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 + + # -- 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 + + 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", { + "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, + }) + + 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" + ), + }) + + +# --------------------------------------------------------------------------- +# 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._enqueue_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", + "DingTalkPermanentError", + "DingTalkStreamRunner", + "DingTalkStreamStallError", + "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..f6d0b1c44 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,804 @@ 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 + + plugin = DingTalkChannel() - async def _fake_get(): - return _FakeCfgInfo() + async def _failing_runner(): + raise RuntimeError("network hiccup") - with patch("flocks.config.config.Config.get", new=_fake_get): - data = await plugin._resolve_outbound_config() + plugin._runner_tasks = [asyncio.create_task(_failing_runner())] + abort_event = asyncio.Event() - assert data["clientId"] == "live_key" - assert data["clientSecret"] == "live_secret" + 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 raising_send(**_): - raise DingTalkApiError( - "throttled", code="Throttling.Api", retryable=True, + 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 _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_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, + ) + 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 + + +# ------------------------------------------------------------------ +# 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) # ------------------------------------------------------------------ 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" }, 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')} - /> -