diff --git a/.github/labeler.yml b/.github/labeler.yml index a1259f44aa43..6780bbe64694 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -9,6 +9,12 @@ - "src/discord/**" - "extensions/discord/**" - "docs/channels/discord.md" +"channel: dingtalk": + - changed-files: + - any-glob-to-any-file: + - "src/dingtalk/**" + - "extensions/dingtalk/**" + - "docs/channels/dingtalk.md" "channel: feishu": - changed-files: - any-glob-to-any-file: diff --git a/.gitignore b/.gitignore index f9f3bc99b8ad..92b944924a0b 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,5 @@ USER.md memory/ .agent/*.json !.agent/workflows/ +package-lock.json local/ diff --git a/README.md b/README.md index dad4a20309db..3104821767d4 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies. ### Channels -- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat). +- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [DingTalk](https://docs.openclaw.ai/channels/dingtalk) (extension), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat). - [Group routing](https://docs.openclaw.ai/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels). ### Apps + nodes diff --git a/docs/channels/dingtalk.md b/docs/channels/dingtalk.md new file mode 100644 index 000000000000..cacb9ce6788d --- /dev/null +++ b/docs/channels/dingtalk.md @@ -0,0 +1,314 @@ +--- +summary: "DingTalk bot overview, features, and configuration" +read_when: + - You want to connect a DingTalk bot + - You are configuring the DingTalk channel +title: DingTalk +--- + +# DingTalk bot + +DingTalk (钉钉) is a team chat platform widely used by companies in China for messaging and collaboration. This plugin connects OpenClaw to a DingTalk bot using the platform's Stream mode (WebSocket) for receiving events, so messages can be received without exposing a public webhook URL. + +--- + +## Plugin required + +Install the DingTalk plugin: + +```bash +openclaw plugins install @openclaw/dingtalk +``` + +Local checkout (when running from a git repo): + +```bash +openclaw plugins install ./extensions/dingtalk +``` + +--- + +## Quickstart + +There are two ways to add the DingTalk channel: + +### Method 1: onboarding wizard (recommended) + +If you just installed OpenClaw, run the wizard: + +```bash +openclaw onboard +``` + +The wizard guides you through: + +1. Creating a DingTalk app and collecting credentials +2. Configuring app credentials in OpenClaw +3. Starting the gateway + +✅ **After configuration**, check gateway status: + +- `openclaw gateway status` +- `openclaw logs --follow` + +### Method 2: CLI setup + +If you already completed initial install, add the channel via CLI: + +```bash +openclaw channels add +``` + +Choose **DingTalk**, then enter the App Key and App Secret. + +✅ **After configuration**, manage the gateway: + +- `openclaw gateway status` +- `openclaw gateway restart` +- `openclaw logs --follow` + +--- + +## Step 1: Create a DingTalk robot + +1. Visit [DingTalk Open Platform](https://open.dingtalk.com/) + +2. Sign in and go to **Application Development** → **Enterprise Internal Development** + +3. Create an application, Get the **AppKey** and **AppSecret** from the app details page + ![Get credentials](../images/dingtalk-step6-credentials.jpg) +4. select **Robot** type + +![Create robot app](../images/dingtalk-step1-create-app.png) + +5. Configure the message receiving mode as **Stream mode** + +![Configure Stream mode](../images/dingtalk-step2-stream-mode.jpg) + +6. **Publish the robot** + +--- + +## Step 2: Configure permissions + +Non-admin users need admin approval. + +Search and enable the following permissions: + +- `Card.Streaming.Write` +- `Card.Instance.Write` +- `qyapi_robot_sendmsg` + +![Configure permissions](../images/dingtalk-step4-permissions.png) + +--- + +## Step 3: Publish the app + +![Publish step 1](../images/dingtalk-step5-publish-1.png) + +![Publish step 2](../images/dingtalk-step5-publish-2.png) + +![Publish step 3](../images/dingtalk-step5-publish-3.png) + +![Publish step 4](../images/dingtalk-step5-publish-4.png) + +Confirm the visibility scope (you can limit to yourself), ensure robot is enabled, then click **Publish**. + +--- + +## Step 4: Configure OpenClaw + +### Configure with the wizard (recommended) + +```bash +openclaw channels add +``` + +Choose **DingTalk** and paste your App Key + App Secret. + +### Configure via config file + +Edit `~/.openclaw/openclaw.json`: + +```json5 +{ + channels: { + dingtalk: { + enabled: true, + dmPolicy: "pairing", + accounts: { + main: { + appKey: "dingxxx", + appSecret: "xxx", + botName: "My AI assistant", + }, + }, + }, + }, +} +``` + +### Configure via environment variables + +```bash +export DINGTALK_APP_KEY="dingxxx" +export DINGTALK_APP_SECRET="xxx" +``` + +--- + +## Step 5: Start + test + +### 1. Start the gateway + +```bash +openclaw gateway +``` + +### 2. Send a test message + +In DingTalk, find your bot and send a message. + +![search robot](../images/dingtalk-step7-robot.png) + +![send message](../images/dingtalk-step7-chat.png) + +### 3. Approve pairing + +By default, the bot replies with a pairing code. Approve it: + +```bash +openclaw pairing approve dingtalk +``` + +After approval, you can chat normally. + +--- + +## Overview + +- **DingTalk bot channel**: DingTalk bot managed by the gateway +- **Deterministic routing**: replies always return to DingTalk +- **Session isolation**: DMs share a main session; groups are isolated +- **Stream mode connection**: WebSocket-based connection via DingTalk SDK, no public URL needed + +--- + +## Access control + +### Direct messages + +- **Default**: `dmPolicy: "open"` +- **Allowlist mode**: set `channels.dingtalk.allowFrom` with allowed user IDs + +### Group chats + +**Group policy** (`channels.dingtalk.groupPolicy`): + +- `"open"` = allow everyone in groups (default) +- `"allowlist"` = only allow `groupAllowFrom` +- `"disabled"` = disable group messages + +--- + +## Get user IDs + +User IDs can be obtained from: + +**Method 1 (recommended)** + +1. Start the gateway and DM the bot +2. Run `openclaw logs --follow` and look for `senderStaffId` or `senderId` + +--- + +## Common commands + +| Command | Description | +| --------- | ----------------- | +| `/status` | Show bot status | +| `/reset` | Reset the session | +| `/model` | Show/switch model | + +> Note: DingTalk does not support native command menus yet, so commands must be sent as text. + +## Gateway management commands + +| Command | Description | +| -------------------------- | ----------------------------- | +| `openclaw gateway status` | Show gateway status | +| `openclaw gateway install` | Install/start gateway service | +| `openclaw gateway stop` | Stop gateway service | +| `openclaw gateway restart` | Restart gateway service | +| `openclaw logs --follow` | Tail gateway logs | + +--- + +## Troubleshooting + +### Bot does not respond in group chats + +1. Ensure the bot is added to the group +2. Ensure you @mention the bot (default behavior) +3. Check `groupPolicy` is not set to `"disabled"` +4. Check logs: `openclaw logs --follow` + +### Bot does not receive messages + +1. Ensure the app is published and approved +2. Ensure Stream mode is enabled for the robot +3. Ensure app permissions are complete +4. Ensure the gateway is running: `openclaw gateway status` +5. Check logs: `openclaw logs --follow` + +### App Secret leak + +1. Reset the App Secret in DingTalk Open Platform +2. Update the App Secret in your config +3. Restart the gateway + +### Message send failures + +1. Ensure the app has `qyapi_robot_sendmsg` permission +2. Ensure the app is published +3. Check logs for detailed errors + +--- + +## Configuration reference + +Full configuration: [Gateway configuration](/gateway/configuration) + +Key options: + +| Setting | Description | Default | +| ---------------------------------- | --------------------------- | ------- | +| `channels.dingtalk.enabled` | Enable/disable channel | `true` | +| `channels.dingtalk.clientId` | App Key (Client ID) | - | +| `channels.dingtalk.clientSecret` | App Secret (Client Secret) | - | +| `channels.dingtalk.dmPolicy` | DM policy | `open` | +| `channels.dingtalk.allowFrom` | DM allowlist (user ID list) | - | +| `channels.dingtalk.groupPolicy` | Group policy | `open` | +| `channels.dingtalk.groupAllowFrom` | Group allowlist | - | + +--- + +## Supported message types + +### Receive + +- ✅ Text +- ✅ Images +- ✅ Files +- ✅ Audio +- ✅ Video +- ⚠️ Rich text (partial support) + +### Send + +- ✅ Text +- ✅ Images +- ✅ Files +- ✅ Markdown +- ⚠️ Interactive cards (partial support) diff --git a/docs/channels/index.md b/docs/channels/index.md index 23bf98915fc8..821df791c4b8 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -18,6 +18,7 @@ Text is supported everywhere; media and reactions vary by channel. - [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs. - [Slack](/channels/slack) — Bolt SDK; workspace apps. - [Feishu](/channels/feishu) — Feishu/Lark bot via WebSocket (plugin, installed separately). +- [DingTalk](/channels/dingtalk) — DingTalk bot via Stream mode (plugin, installed separately). - [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook. - [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately). - [Signal](/channels/signal) — signal-cli; privacy-focused. diff --git a/docs/images/dingtalk-dashboard-config.png b/docs/images/dingtalk-dashboard-config.png new file mode 100644 index 000000000000..34e52138a29c Binary files /dev/null and b/docs/images/dingtalk-dashboard-config.png differ diff --git a/docs/images/dingtalk-step1-create-app.png b/docs/images/dingtalk-step1-create-app.png new file mode 100644 index 000000000000..17fcbbf40868 Binary files /dev/null and b/docs/images/dingtalk-step1-create-app.png differ diff --git a/docs/images/dingtalk-step2-stream-mode.jpg b/docs/images/dingtalk-step2-stream-mode.jpg new file mode 100644 index 000000000000..5c5849eec4eb Binary files /dev/null and b/docs/images/dingtalk-step2-stream-mode.jpg differ diff --git a/docs/images/dingtalk-step2-stream-mode.png b/docs/images/dingtalk-step2-stream-mode.png new file mode 100644 index 000000000000..73830d9a1afc Binary files /dev/null and b/docs/images/dingtalk-step2-stream-mode.png differ diff --git a/docs/images/dingtalk-step3-publish.png b/docs/images/dingtalk-step3-publish.png new file mode 100644 index 000000000000..f918fcadad9b Binary files /dev/null and b/docs/images/dingtalk-step3-publish.png differ diff --git a/docs/images/dingtalk-step4-permissions.png b/docs/images/dingtalk-step4-permissions.png new file mode 100644 index 000000000000..89560a3cee43 Binary files /dev/null and b/docs/images/dingtalk-step4-permissions.png differ diff --git a/docs/images/dingtalk-step5-publish-1.png b/docs/images/dingtalk-step5-publish-1.png new file mode 100644 index 000000000000..1067b2b1549c Binary files /dev/null and b/docs/images/dingtalk-step5-publish-1.png differ diff --git a/docs/images/dingtalk-step5-publish-2.png b/docs/images/dingtalk-step5-publish-2.png new file mode 100644 index 000000000000..84669bc71b79 Binary files /dev/null and b/docs/images/dingtalk-step5-publish-2.png differ diff --git a/docs/images/dingtalk-step5-publish-3.png b/docs/images/dingtalk-step5-publish-3.png new file mode 100644 index 000000000000..9fed8ba273fa Binary files /dev/null and b/docs/images/dingtalk-step5-publish-3.png differ diff --git a/docs/images/dingtalk-step5-publish-4.png b/docs/images/dingtalk-step5-publish-4.png new file mode 100644 index 000000000000..3c314e0e5d48 Binary files /dev/null and b/docs/images/dingtalk-step5-publish-4.png differ diff --git a/docs/images/dingtalk-step6-credentials.jpg b/docs/images/dingtalk-step6-credentials.jpg new file mode 100644 index 000000000000..aa5389814f07 Binary files /dev/null and b/docs/images/dingtalk-step6-credentials.jpg differ diff --git a/docs/images/dingtalk-step6-credentials.png b/docs/images/dingtalk-step6-credentials.png new file mode 100644 index 000000000000..28816dc5130d Binary files /dev/null and b/docs/images/dingtalk-step6-credentials.png differ diff --git a/docs/images/dingtalk-step7-chat.png b/docs/images/dingtalk-step7-chat.png new file mode 100644 index 000000000000..3088c3e86596 Binary files /dev/null and b/docs/images/dingtalk-step7-chat.png differ diff --git a/docs/images/dingtalk-step7-robot.png b/docs/images/dingtalk-step7-robot.png new file mode 100644 index 000000000000..c665b61c4dd0 Binary files /dev/null and b/docs/images/dingtalk-step7-robot.png differ diff --git a/docs/zh-CN/channels/dingtalk.md b/docs/zh-CN/channels/dingtalk.md new file mode 100644 index 000000000000..4d2ca4114da5 --- /dev/null +++ b/docs/zh-CN/channels/dingtalk.md @@ -0,0 +1,311 @@ +--- +summary: "钉钉机器人支持状态、功能和配置" +read_when: + - 您想要连接钉钉机器人 + - 您正在配置钉钉渠道 +title: 钉钉 +--- + +# 钉钉机器人 + +钉钉(DingTalk)是中国广泛使用的企业协作平台,提供即时通讯和办公协作功能。此插件通过钉钉的 Stream 模式(WebSocket)将 OpenClaw 连接到钉钉机器人,无需暴露公网 webhook URL 即可接收消息。 + +--- + +## 需要插件 + +安装 DingTalk 插件: + +```bash +openclaw plugins install @openclaw/dingtalk +``` + +本地 checkout(在 git 仓库内运行): + +```bash +openclaw plugins install ./extensions/dingtalk +``` + +--- + +## 快速开始 + +添加钉钉渠道有两种方式: + +### 方式一:通过安装向导添加(推荐) + +如果您刚安装完 OpenClaw,可以直接运行向导,根据提示添加钉钉: + +```bash +openclaw onboard +``` + +向导会引导您完成: + +1. 创建钉钉应用并获取凭证 +2. 配置应用凭证 +3. 启动网关 + +✅ **完成配置后**,您可以使用以下命令检查网关状态: + +- `openclaw gateway status` - 查看网关运行状态 +- `openclaw logs --follow` - 查看实时日志 + +### 方式二:通过命令行添加 + +如果您已经完成了初始安装,可以用以下命令添加钉钉渠道: + +```bash +openclaw channels add +``` + +然后根据交互式提示选择 DingTalk,输入 App Key 和 App Secret 即可。 + +✅ **完成配置后**,您可以使用以下命令管理网关: + +- `openclaw gateway status` - 查看网关运行状态 +- `openclaw gateway restart` - 重启网关以应用新配置 +- `openclaw logs --follow` - 查看实时日志 + +--- + +## 第一步:申请钉钉机器人 + +1. 打开 [钉钉开放平台](https://open.dingtalk.com/) + +2. 登录后进入 **应用开发** → **企业内部开发** + +3. 创建应用,在应用详情页获取 **AppKey** 和 **AppSecret** + ![获取应用凭证](../../images/dingtalk-step6-credentials.jpg) +4. **应用能力** → **添加应用能力**,选择 **机器人** 类型 + ![创建机器人应用](../../images/dingtalk-step1-create-app.png) + +5. 配置机器人的消息接收模式为 **Stream 模式** + +![配置 Stream 模式](../../images/dingtalk-step2-stream-mode.jpg) + +6. **发布钉钉机器人** + +--- + +## 第二步:钉钉企业应用权限开通 + +非管理员用户需管理员审批。 + +逐个搜索打开下列权限: + +- `Card.Streaming.Write` +- `Card.Instance.Write` +- `qyapi_robot_sendmsg` + +![配置应用权限](../../images/dingtalk-step4-permissions.png) + +--- + +## 第三步:发布企业应用 + +![发布步骤 1](../../images/dingtalk-step5-publish-1.png) + +![发布步骤 2](../../images/dingtalk-step5-publish-2.png) + +![发布步骤 3](../../images/dingtalk-step5-publish-3.png) + +![发布步骤 4](../../images/dingtalk-step5-publish-4.png) + +确认可见范围仅自己,启用了机器人后,点 **发布**。 + +--- + +## 第四步:配置 OpenClaw + +### 通过向导配置(推荐) + +运行以下命令,根据提示粘贴 App Key 和 App Secret: + +```bash +openclaw channels add +``` + +选择 **DingTalk**,然后输入您在第一步获取的凭证即可。 + +### 通过配置文件配置 + +编辑 `~/.openclaw/openclaw.json`: + +```json5 +{ + channels: { + dingtalk: { + enabled: true, + clientId: "dingxxx", + clientSecret: "xxx", + dmPolicy: "open", + groupPolicy: "open", + }, + }, +} +``` + +--- + +## 第五步:启动并测试 + +### 1. 启动网关 + +```bash +openclaw gateway +``` + +### 2. 发送测试消息 + +在钉钉中搜索找到您创建的机器人,发送一条消息。 + +![找到创建的钉钉机器人](../../images/dingtalk-step7-robot.png) + +![发送消息](../../images/dingtalk-step7-chat.png) + +### 3. 配对授权(仅当启用 `dmPolicy: "pairing"`) + +如果将私聊策略设为 `pairing`,机器人会回复 **配对码**,需要管理员批准: + +```bash +openclaw pairing approve dingtalk <配对码> +``` + +--- + +## 介绍 + +- **钉钉机器人渠道**:由网关管理的钉钉机器人 +- **确定性路由**:回复始终返回钉钉,模型不会选择渠道 +- **会话隔离**:私聊共享主会话;群组独立隔离 +- **Stream 模式连接**:使用钉钉 SDK 的 WebSocket 连接,无需公网 URL + +--- + +## 访问控制 + +### 私聊访问 + +- **默认**:`dmPolicy: "open"` +- **白名单模式**:通过 `channels.dingtalk.allowFrom` 配置允许的用户 ID + +### 群组访问 + +**群组策略**(`channels.dingtalk.groupPolicy`): + +- `"open"` = 允许群组中所有人(默认) +- `"allowlist"` = 仅允许 `groupAllowFrom` 中的用户 +- `"disabled"` = 禁用群组消息 + +--- + +## 获取用户 ID(staffId/userId) + +用户 ID 可以通过以下方式获取: + +**方法一**(推荐): + +1. 启动网关并给机器人发消息 +2. 运行 `openclaw logs --follow` 查看日志中的 `senderStaffId` 或 `senderId` + +**方法二**: +查看配对请求列表,其中包含用户 ID: + +```bash +openclaw pairing list dingtalk +``` + +--- + +## 常用命令 + +| 命令 | 说明 | +| --------- | -------------- | +| `/status` | 查看机器人状态 | +| `/reset` | 重置对话会话 | +| `/model` | 查看/切换模型 | + +> 注意:钉钉目前不支持原生命令菜单,命令需要以文本形式发送。 + +## 网关管理命令 + +在配置和使用钉钉渠道时,您可能需要使用以下网关管理命令: + +| 命令 | 说明 | +| -------------------------- | ----------------- | +| `openclaw gateway status` | 查看网关运行状态 | +| `openclaw gateway install` | 安装/启动网关服务 | +| `openclaw gateway stop` | 停止网关服务 | +| `openclaw gateway restart` | 重启网关服务 | +| `openclaw logs --follow` | 实时查看日志输出 | + +--- + +## 故障排除 + +### 机器人在群组中不响应 + +1. 检查机器人是否已添加到群组 +2. 检查是否 @了机器人(默认需要 @提及) +3. 检查 `groupPolicy` 是否为 `"disabled"` +4. 查看日志:`openclaw logs --follow` + +### 机器人收不到消息 + +1. 检查应用是否已发布并审批通过 +2. 检查是否启用了 Stream 模式 +3. 检查应用权限是否完整 +4. 检查网关是否正在运行:`openclaw gateway status` +5. 查看实时日志:`openclaw logs --follow` + +### App Secret 泄露怎么办 + +1. 在钉钉开放平台重置 App Secret +2. 更新配置文件中的 App Secret +3. 重启网关 + +### 发送消息失败 + +1. 检查应用是否有 `qyapi_robot_sendmsg` 权限 +2. 检查应用是否已发布 +3. 查看日志获取详细错误信息 + +--- + +## 配置参考 + +完整配置请参考:[网关配置](/gateway/configuration) + +主要选项: + +| 配置项 | 说明 | 默认值 | +| ---------------------------------- | -------------------------- | ------ | +| `channels.dingtalk.enabled` | 启用/禁用渠道 | `true` | +| `channels.dingtalk.clientId` | 应用 App Key(Client ID) | - | +| `channels.dingtalk.clientSecret` | 应用 App Secret | - | +| `channels.dingtalk.dmPolicy` | 私聊策略 | `open` | +| `channels.dingtalk.allowFrom` | 私聊白名单(用户 ID 列表) | - | +| `channels.dingtalk.groupPolicy` | 群组策略 | `open` | +| `channels.dingtalk.groupAllowFrom` | 群组白名单 | - | + +--- + +## 支持的消息类型 + +### 接收 + +- ✅ 文本消息 +- ✅ 图片 +- ✅ 文件 +- ✅ 音频 +- ✅ 视频 +- ⚠️ 富文本(部分支持) + +### 发送 + +- ✅ 文本消息 +- ✅ 图片 +- ✅ 文件 +- ✅ Markdown +- ⚠️ 互动卡片(部分支持) diff --git a/extensions/dingtalk/index.ts b/extensions/dingtalk/index.ts new file mode 100644 index 000000000000..78853a2d318e --- /dev/null +++ b/extensions/dingtalk/index.ts @@ -0,0 +1,15 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { dingtalkPlugin } from "./src/channel.js"; + +const plugin = { + id: "dingtalk", + name: "DingTalk", + description: "DingTalk (钉钉) channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerChannel({ plugin: dingtalkPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/dingtalk/openclaw.plugin.json b/extensions/dingtalk/openclaw.plugin.json new file mode 100644 index 000000000000..710014375fdf --- /dev/null +++ b/extensions/dingtalk/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "dingtalk", + "channels": ["dingtalk"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/dingtalk/package.json b/extensions/dingtalk/package.json new file mode 100644 index 000000000000..efd83bb62b59 --- /dev/null +++ b/extensions/dingtalk/package.json @@ -0,0 +1,44 @@ +{ + "name": "@openclaw/dingtalk", + "version": "2026.2.5", + "description": "OpenClaw DingTalk channel plugin", + "type": "module", + "dependencies": { + "@ffmpeg-installer/ffmpeg": "^1.1.0", + "axios": "^1.6.0", + "dingtalk-stream": "^2.1.4", + "form-data": "^4.0.0" + }, + "devDependencies": { + "openclaw": "workspace:*" + }, + "peerDependencies": { + "openclaw": ">=2026.2.4" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "dingtalk", + "label": "DingTalk", + "selectionLabel": "DingTalk Open Platform", + "detailLabel": "DingTalk Bot", + "docsPath": "/channels/dingtalk", + "docsLabel": "dingtalk", + "blurb": "DingTalk bot via Stream mode with AI Card streaming.", + "aliases": [ + "dd", + "ding", + "dingtalk-connector" + ], + "order": 70, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/dingtalk", + "localPath": "extensions/dingtalk", + "defaultChoice": "npm" + } + } +} diff --git a/extensions/dingtalk/src/channel.ts b/extensions/dingtalk/src/channel.ts new file mode 100644 index 000000000000..c36535968a8f --- /dev/null +++ b/extensions/dingtalk/src/channel.ts @@ -0,0 +1,336 @@ +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + dingtalkOutbound, + formatPairingApproveHint, + listDingTalkAccountIds, + monitorDingTalkProvider, + normalizeDingTalkTarget, + PAIRING_APPROVED_MESSAGE, + probeDingTalk, + readDingTalkAllowFromStore, + readDingTalkKnownUsers, + resolveDefaultDingTalkAccountId, + resolveDingTalkAccount, + resolveDingTalkConfig, + resolveDingTalkGroupRequireMention, + setAccountEnabledInConfigSection, + type ChannelAccountSnapshot, + type ChannelPlugin, + type ChannelStatusIssue, + type ResolvedDingTalkAccount, +} from "openclaw/plugin-sdk"; +import { DingTalkConfigSchema } from "./config-schema.js"; +import { dingtalkOnboardingAdapter } from "./onboarding.js"; + +const meta = { + id: "dingtalk", + label: "DingTalk", + selectionLabel: "DingTalk Open Platform", + detailLabel: "DingTalk Bot", + docsPath: "/channels/dingtalk", + docsLabel: "dingtalk", + blurb: "DingTalk bot via Stream mode with AI Card streaming.", + aliases: ["dd", "ding", "dingtalk-connector"], + order: 70, + quickstartAllowFrom: true, +}; + +const normalizeAllowEntry = (entry: string) => + entry.replace(/^(dingtalk|dingtalk-connector|dd|ding):/i, "").trim(); + +const buildPeerDirectoryEntries = async (params: { + cfg: Parameters[0]["cfg"]; + accountId?: string | null; +}) => { + const resolved = resolveDingTalkConfig({ + cfg: params.cfg, + accountId: params.accountId ?? undefined, + }); + const configAllowFrom = resolved.allowFrom + .map((entry) => String(entry).trim()) + .filter((entry) => Boolean(entry) && entry !== "*") + .map((entry) => normalizeAllowEntry(entry)); + const storeAllowFrom = await readDingTalkAllowFromStore().catch(() => []); + const knownUsers = await readDingTalkKnownUsers().catch(() => []); + const knownById = new Map( + knownUsers.map((item) => [normalizeAllowEntry(item.userId), item.name] as const), + ); + const userIds = Array.from(new Set([...configAllowFrom, ...storeAllowFrom])) + .map((entry) => normalizeAllowEntry(entry)) + .filter(Boolean); + return userIds.map((id) => { + const name = knownById.get(id); + return name ? ({ kind: "user", id, name } as const) : ({ kind: "user", id } as const); + }); +}; + +export const dingtalkPlugin: ChannelPlugin = { + id: "dingtalk", + meta, + onboarding: dingtalkOnboardingAdapter, + pairing: { + idLabel: "dingTalkUserId", + normalizeAllowEntry, + notifyApproval: async ({ cfg, id }) => { + await dingtalkOutbound.sendText({ cfg, to: id, text: PAIRING_APPROVED_MESSAGE }); + }, + }, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: false, + threads: false, + polls: false, + nativeCommands: false, + blockStreaming: false, + }, + agentPrompt: { + messageToolHints: () => [ + "- 钉钉提醒/定时主动发送前先读取当前会话接收人;优先 `deliveryContext.to`,其次 `lastTo`。两者都没有时先向用户索要 `user:` 或 `group:`。", + '- 钉钉定时任务必须使用:`job.sessionTarget="isolated"` + `job.payload.kind="agentTurn"` + `job.delivery.mode="announce"` + `job.delivery.channel="dingtalk"`,正文放 `job.payload.message`。', + '- 禁止使用:`job.sessionTarget="main"`、`job.payload.kind="systemEvent"`、`[[reply_to_current]]`、`[[reply_to:*]]`,以及把“最后联系人”等自然语言字面量当作 `to/target`。', + "- 面向用户的确认回复只能描述结果本身,不写实现细节。禁止输出 `isolated`、`agentTurn`、`session_status`、`deliveryContext`、`lastTo`、`channel`、`to` 等内部术语或字段名;禁止出现“使用独立会话(isolated)执行”“直接指定钉钉通道和接收人”这类说明句。", + "- 用户可见确认语固定模板:第一句“已设置{时间}提醒:{内容}。”;可选第二句“到时我会在钉钉提醒你。”;禁止列表/项目符号/步骤说明。", + ], + }, + reload: { configPrefixes: ["channels.dingtalk"] }, + outbound: dingtalkOutbound, + messaging: { + normalizeTarget: normalizeDingTalkTarget, + targetResolver: { + looksLikeId: (raw, normalized) => { + const value = (normalized ?? raw).trim(); + if (!value) { + return false; + } + if (/^(user|group|dm):/i.test(value)) { + return true; + } + if (/^\d{8,}$/.test(value)) { + return true; + } + return value.includes("=") || value.length > 30; + }, + hint: "", + }, + }, + configSchema: buildChannelConfigSchema(DingTalkConfigSchema), + config: { + listAccountIds: (cfg) => listDingTalkAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveDingTalkAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultDingTalkAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg, + sectionKey: "dingtalk", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg, + sectionKey: "dingtalk", + accountId, + clearBaseFields: ["clientId", "clientSecret", "clientSecretFile", "name"], + }), + isConfigured: (account) => account.tokenSource !== "none", + describeAccount: (account): ChannelAccountSnapshot => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.tokenSource !== "none", + tokenSource: account.tokenSource, + }), + resolveAllowFrom: ({ cfg, accountId }) => + resolveDingTalkConfig({ cfg, accountId: accountId ?? undefined }).allowFrom.map((entry) => + String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => (entry === "*" ? entry : normalizeAllowEntry(entry))) + .map((entry) => (entry === "*" ? entry : entry.toLowerCase())), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean(cfg.channels?.dingtalk?.accounts?.[resolvedAccountId]); + const basePath = useAccountPath + ? `channels.dingtalk.accounts.${resolvedAccountId}.` + : "channels.dingtalk."; + return { + policy: account.config.dmPolicy ?? "open", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("dingtalk"), + normalizeEntry: normalizeAllowEntry, + }; + }, + }, + groups: { + resolveRequireMention: ({ cfg, accountId, groupId }) => { + if (!groupId) { + return true; + } + return resolveDingTalkGroupRequireMention({ + cfg, + accountId: accountId ?? undefined, + chatId: groupId, + }); + }, + }, + directory: { + self: async () => null, + listPeers: async ({ cfg, accountId, query, limit }) => { + const peers = await buildPeerDirectoryEntries({ cfg, accountId: accountId ?? undefined }); + const normalizedQuery = query?.trim().toLowerCase() ?? ""; + return peers + .filter((entry) => { + if (!normalizedQuery) { + return true; + } + const idMatch = entry.id.toLowerCase().includes(normalizedQuery); + const nameMatch = entry.name?.toLowerCase().includes(normalizedQuery) ?? false; + return idMatch || nameMatch; + }) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((entry) => ({ ...entry })); + }, + listGroups: async ({ cfg, accountId, query, limit }) => { + const resolved = resolveDingTalkConfig({ cfg, accountId: accountId ?? undefined }); + const normalizedQuery = query?.trim().toLowerCase() ?? ""; + const groups = Object.keys(resolved.groups ?? {}) + .filter((id) => (normalizedQuery ? id.toLowerCase().includes(normalizedQuery) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "group", id }) as const); + if (!normalizedQuery) { + return groups; + } + const peers = await buildPeerDirectoryEntries({ cfg, accountId: accountId ?? undefined }); + const matchedPeers = peers.filter((entry) => { + const idMatch = entry.id.toLowerCase().includes(normalizedQuery); + const nameMatch = entry.name?.toLowerCase().includes(normalizedQuery) ?? false; + return idMatch || nameMatch; + }); + const merged = [...groups, ...matchedPeers]; + return merged.slice(0, limit && limit > 0 ? limit : undefined); + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => { + const issues: ChannelStatusIssue[] = []; + for (const account of accounts) { + if (account.tokenSource === "none") { + issues.push({ + channel: "dingtalk", + accountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + kind: "config", + message: "DingTalk app key/secret not configured", + }); + } + } + return issues; + }, + buildChannelSummary: async ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + tokenSource: snapshot.tokenSource ?? "none", + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs }) => + probeDingTalk(account.config.clientId ?? "", account.config.clientSecret ?? "", timeoutMs), + buildAccountSnapshot: ({ account, runtime, probe }) => { + const configured = account.tokenSource !== "none"; + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + tokenSource: account.tokenSource, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }; + }, + logSelfId: ({ account, runtime }) => { + const appId = account.config.clientId; + if (appId) { + runtime.log?.(`dingtalk:${appId}`); + } + }, + }, + gateway: { + startAccount: async (ctx) => { + const { account, log, setStatus, abortSignal, cfg, runtime } = ctx; + const { clientId, clientSecret } = account.config; + if (!clientId || !clientSecret) { + throw new Error("DingTalk app key/secret not configured"); + } + + log?.info(`[${account.accountId}] starting DingTalk provider`); + setStatus({ + accountId: account.accountId, + running: true, + lastStartAt: Date.now(), + }); + + try { + await monitorDingTalkProvider({ + accountId: account.accountId, + config: cfg, + runtime, + abortSignal, + onConnected: () => { + setStatus({ + accountId: account.accountId, + connected: true, + running: true, + lastError: null, + }); + }, + onDisconnected: () => { + setStatus({ + accountId: account.accountId, + connected: false, + }); + }, + onInbound: () => { + setStatus({ + accountId: account.accountId, + lastInboundAt: Date.now(), + connected: true, + running: true, + }); + }, + }); + } catch (err) { + setStatus({ + accountId: account.accountId, + running: false, + lastError: err instanceof Error ? err.message : String(err), + }); + throw err; + } + }, + }, +}; diff --git a/extensions/dingtalk/src/config-schema.ts b/extensions/dingtalk/src/config-schema.ts new file mode 100644 index 000000000000..792f028d1af0 --- /dev/null +++ b/extensions/dingtalk/src/config-schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +const allowFromEntry = z.union([z.string(), z.number()]); + +export const DingTalkConfigSchema = z + .object({ + enabled: z.boolean().optional(), + clientId: z.string().optional(), + clientSecret: z.string().optional(), + dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).default("open"), + groupPolicy: z.enum(["open", "allowlist", "disabled"]).default("open"), + allowFrom: z.array(allowFromEntry).optional(), + groupAllowFrom: z.array(allowFromEntry).optional(), + }) + .strict(); diff --git a/extensions/dingtalk/src/onboarding.ts b/extensions/dingtalk/src/onboarding.ts new file mode 100644 index 000000000000..283dda5aca11 --- /dev/null +++ b/extensions/dingtalk/src/onboarding.ts @@ -0,0 +1,245 @@ +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, + DmPolicy, + OpenClawConfig, + WizardPrompter, +} from "openclaw/plugin-sdk"; +import { + addWildcardAllowFrom, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + normalizeAccountId, + promptAccountId, +} from "openclaw/plugin-sdk"; +import { + listDingTalkAccountIds, + resolveDefaultDingTalkAccountId, + resolveDingTalkAccount, +} from "openclaw/plugin-sdk"; + +const channel = "dingtalk" as const; + +function normalizeAllowEntry(entry: string): string { + return entry.replace(/^(dingtalk|dingtalk-connector|dd|ding):/i, "").trim(); +} + +function setDingTalkDmPolicy(cfg: OpenClawConfig, policy: DmPolicy): OpenClawConfig { + const allowFrom = + policy === "open" ? addWildcardAllowFrom(cfg.channels?.dingtalk?.allowFrom) : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + dingtalk: { + ...cfg.channels?.dingtalk, + enabled: true, + dmPolicy: policy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }; +} + +async function noteDingTalkSetup(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "Create a DingTalk app and enable Stream mode for the bot.", + "Copy the App Key (clientId) and App Secret (clientSecret).", + "The gateway should be running for stream delivery.", + `Docs: ${formatDocsLink("/channels/dingtalk", "channels/dingtalk")}`, + ].join("\n"), + "DingTalk setup", + ); +} + +async function promptDingTalkAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string | null; +}): Promise { + const { cfg, prompter } = params; + const accountId = normalizeAccountId(params.accountId); + const isDefault = accountId === DEFAULT_ACCOUNT_ID; + const existingAllowFrom = isDefault + ? (cfg.channels?.dingtalk?.allowFrom ?? []) + : (cfg.channels?.dingtalk?.accounts?.[accountId]?.allowFrom ?? []); + + const entry = await prompter.text({ + message: "DingTalk allowFrom (user id or *)", + placeholder: "123456789", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + return undefined; + }, + }); + + const parsed = String(entry) + .split(/[\n,;]+/g) + .map((item) => normalizeAllowEntry(item)) + .filter(Boolean); + const merged = [ + ...existingAllowFrom.map((item) => normalizeAllowEntry(String(item))), + ...parsed, + ].filter(Boolean); + const unique = Array.from(new Set(merged)); + + if (isDefault) { + return { + ...cfg, + channels: { + ...cfg.channels, + dingtalk: { + ...cfg.channels?.dingtalk, + enabled: true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }, + }; + } + + return { + ...cfg, + channels: { + ...cfg.channels, + dingtalk: { + ...cfg.channels?.dingtalk, + enabled: true, + accounts: { + ...cfg.channels?.dingtalk?.accounts, + [accountId]: { + ...cfg.channels?.dingtalk?.accounts?.[accountId], + enabled: cfg.channels?.dingtalk?.accounts?.[accountId]?.enabled ?? true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }, + }, + }, + }; +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "DingTalk", + channel, + policyKey: "channels.dingtalk.dmPolicy", + allowFromKey: "channels.dingtalk.allowFrom", + getCurrent: (cfg) => cfg.channels?.dingtalk?.dmPolicy ?? "open", + setPolicy: (cfg, policy) => setDingTalkDmPolicy(cfg, policy), + promptAllowFrom: promptDingTalkAllowFrom, +}; + +function updateDingTalkConfig( + cfg: OpenClawConfig, + accountId: string, + updates: { clientId?: string; clientSecret?: string; enabled?: boolean }, +): OpenClawConfig { + const isDefault = accountId === DEFAULT_ACCOUNT_ID; + const next = { ...cfg } as OpenClawConfig; + const dingtalk = { ...next.channels?.dingtalk } as Record; + const accounts = dingtalk.accounts + ? { ...(dingtalk.accounts as Record) } + : undefined; + + if (isDefault && !accounts) { + return { + ...next, + channels: { + ...next.channels, + dingtalk: { + ...dingtalk, + ...updates, + enabled: updates.enabled ?? true, + }, + }, + }; + } + + const resolvedAccounts = accounts ?? {}; + const existing = (resolvedAccounts[accountId] as Record) ?? {}; + resolvedAccounts[accountId] = { + ...existing, + ...updates, + enabled: updates.enabled ?? true, + }; + + return { + ...next, + channels: { + ...next.channels, + dingtalk: { + ...dingtalk, + accounts: resolvedAccounts, + }, + }, + }; +} + +export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + dmPolicy, + getStatus: async ({ cfg }) => { + const configured = listDingTalkAccountIds(cfg).some((id) => { + const acc = resolveDingTalkAccount({ cfg, accountId: id }); + return acc.tokenSource !== "none"; + }); + return { + channel, + configured, + statusLines: [`DingTalk: ${configured ? "configured" : "needs app credentials"}`], + selectionHint: configured ? "configured" : "requires app credentials", + quickstartScore: configured ? 1 : 10, + }; + }, + configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { + let next = cfg; + const override = accountOverrides.dingtalk?.trim(); + const defaultId = resolveDefaultDingTalkAccountId(next); + let accountId = override ? normalizeAccountId(override) : defaultId; + + if (shouldPromptAccountIds && !override) { + accountId = await promptAccountId({ + cfg: next, + prompter, + label: "DingTalk", + currentId: accountId, + listAccountIds: listDingTalkAccountIds, + defaultAccountId: defaultId, + }); + } + + await noteDingTalkSetup(prompter); + + const resolved = resolveDingTalkAccount({ cfg: next, accountId }); + const clientId = String( + await prompter.text({ + message: "DingTalk App Key (clientId)", + placeholder: "dingabc123", + initialValue: resolved.config.clientId || undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }), + ).trim(); + + const clientSecret = String( + await prompter.text({ + message: "DingTalk App Secret (clientSecret)", + placeholder: "secret", + initialValue: resolved.config.clientSecret || undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }), + ).trim(); + + next = updateDingTalkConfig(next, accountId, { + clientId, + clientSecret, + enabled: true, + }); + + return { cfg: next, accountId }; + }, +}; diff --git a/package.json b/package.json index b06792b619f2..be76ad3e790f 100644 --- a/package.json +++ b/package.json @@ -196,7 +196,7 @@ "minimumReleaseAge": 2880, "overrides": { "fast-xml-parser": "5.3.4", - "form-data": "2.5.4", + "form-data": "2.5.5", "@hono/node-server>hono": "4.11.8", "hono": "4.11.8", "qs": "6.14.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92b7e5ab59ff..d7b1a4dfc013 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: fast-xml-parser: 5.3.4 - form-data: 2.5.4 + form-data: 2.5.5 '@hono/node-server>hono': 4.11.8 hono: 4.11.8 qs: 6.14.1 @@ -297,6 +297,25 @@ importers: specifier: workspace:* version: link:../.. + extensions/dingtalk: + dependencies: + '@ffmpeg-installer/ffmpeg': + specifier: ^1.1.0 + version: 1.1.0 + axios: + specifier: ^1.6.0 + version: 1.13.4(debug@4.4.3) + dingtalk-stream: + specifier: ^2.1.4 + version: 2.1.4 + form-data: + specifier: 2.5.5 + version: 2.5.5 + devDependencies: + openclaw: + specifier: workspace:* + version: link:../.. + extensions/discord: devDependencies: openclaw: @@ -1049,6 +1068,49 @@ packages: '@eshaz/web-worker@1.2.2': resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==} + '@ffmpeg-installer/darwin-arm64@4.1.5': + resolution: {integrity: sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==} + cpu: [arm64] + os: [darwin] + + '@ffmpeg-installer/darwin-x64@4.1.0': + resolution: {integrity: sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==} + cpu: [x64] + os: [darwin] + + '@ffmpeg-installer/ffmpeg@1.1.0': + resolution: {integrity: sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==} + + '@ffmpeg-installer/linux-arm64@4.1.4': + resolution: {integrity: sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==} + cpu: [arm64] + os: [linux] + + '@ffmpeg-installer/linux-arm@4.1.3': + resolution: {integrity: sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==} + cpu: [arm] + os: [linux] + + '@ffmpeg-installer/linux-ia32@4.1.0': + resolution: {integrity: sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==} + cpu: [ia32] + os: [linux] + + '@ffmpeg-installer/linux-x64@4.1.0': + resolution: {integrity: sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==} + cpu: [x64] + os: [linux] + + '@ffmpeg-installer/win32-ia32@4.1.0': + resolution: {integrity: sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==} + cpu: [ia32] + os: [win32] + + '@ffmpeg-installer/win32-x64@4.1.0': + resolution: {integrity: sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==} + cpu: [x64] + os: [win32] + '@google/genai@1.40.0': resolution: {integrity: sha512-fhIww8smT0QYRX78qWOiz/nIQhHMF5wXOrlXvj33HBrz3vKDBb+wibLcEmTA+L9dmPD4KmfNr7UF3LDQVTXNjA==} engines: {node: '>=20.0.0'} @@ -3417,6 +3479,9 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + dingtalk-stream@2.1.4: + resolution: {integrity: sha512-rgQbXLGWfASuB9onFcqXTnRSj4ZotimhBOnzrB4kS19AaU9lshXiuofs1GAYcKh5uzPWCAuEs3tMtiadTQWP4A==} + discord-api-types@0.38.37: resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==} @@ -3658,10 +3723,9 @@ packages: forever-agent@0.6.1: resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} - form-data@2.5.4: - resolution: {integrity: sha512-Y/3MmRiR8Nd+0CUtrbvcKtKzLWiUfpQ7DFVggH8PwmGt/0r7RSy32GuP4hpCJlQNEBusisSx1DLtD8uD386HJQ==} + form-data@2.5.5: + resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==} engines: {node: '>= 0.12'} - deprecated: This version has an incorrect dependency; please use v2.5.5 formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} @@ -3783,10 +3847,6 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-own@1.0.1: - resolution: {integrity: sha512-RDKhzgQTQfMaLvIFhjahU+2gGnRBK6dYOd5Gd9BzkmnBneOCRYjRC003RIMrdAbH52+l+CnMS4bBCXGer8tEhg==} - deprecated: This project is not maintained. Use Object.hasOwn() instead. - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -6352,6 +6412,41 @@ snapshots: '@eshaz/web-worker@1.2.2': optional: true + '@ffmpeg-installer/darwin-arm64@4.1.5': + optional: true + + '@ffmpeg-installer/darwin-x64@4.1.0': + optional: true + + '@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 + + '@ffmpeg-installer/linux-arm64@4.1.4': + optional: true + + '@ffmpeg-installer/linux-arm@4.1.3': + optional: true + + '@ffmpeg-installer/linux-ia32@4.1.0': + optional: true + + '@ffmpeg-installer/linux-x64@4.1.0': + optional: true + + '@ffmpeg-installer/win32-ia32@4.1.0': + optional: true + + '@ffmpeg-installer/win32-x64@4.1.0': + optional: true + '@google/genai@1.40.0': dependencies: google-auth-library: 10.5.0 @@ -7689,7 +7784,7 @@ snapshots: '@types/retry': 0.12.0 axios: 1.13.4 eventemitter3: 5.0.4 - form-data: 2.5.4 + form-data: 2.5.5 is-electron: 2.2.2 is-stream: 2.0.1 p-queue: 6.6.2 @@ -8198,7 +8293,7 @@ snapshots: '@types/caseless': 0.12.5 '@types/node': 25.2.1 '@types/tough-cookie': 4.0.5 - form-data: 2.5.4 + form-data: 2.5.5 '@types/retry@0.12.0': {} @@ -8592,7 +8687,7 @@ snapshots: axios@1.13.4(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) - form-data: 2.5.4 + form-data: 2.5.5 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug @@ -8881,6 +8976,16 @@ snapshots: diff@8.0.3: {} + dingtalk-stream@2.1.4: + dependencies: + axios: 1.13.4(debug@4.4.3) + debug: 4.4.3 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + discord-api-types@0.38.37: {} discord-api-types@0.38.38: {} @@ -9177,12 +9282,12 @@ snapshots: forever-agent@0.6.1: {} - form-data@2.5.4: + form-data@2.5.5: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - has-own: 1.0.1 + hasown: 2.0.2 mime-types: 2.1.35 safe-buffer: 5.2.1 @@ -9337,8 +9442,6 @@ snapshots: has-flag@4.0.0: {} - has-own@1.0.1: {} - has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -10486,7 +10589,7 @@ snapshots: combined-stream: 1.0.8 extend: 3.0.2 forever-agent: 0.6.1 - form-data: 2.5.4 + form-data: 2.5.5 har-validator: 5.1.5 http-signature: 1.2.0 is-typedarray: 1.0.0 diff --git a/src/channels/plugins/normalize/dingtalk.ts b/src/channels/plugins/normalize/dingtalk.ts new file mode 100644 index 000000000000..bbd06a048258 --- /dev/null +++ b/src/channels/plugins/normalize/dingtalk.ts @@ -0,0 +1,5 @@ +export function normalizeDingTalkTarget(raw: string): string { + let normalized = raw.replace(/^(dingtalk|dingtalk-connector|dd|ding):/i, "").trim(); + normalized = normalized.replace(/^(user|group):/i, "").trim(); + return normalized; +} diff --git a/src/channels/plugins/outbound/dingtalk.ts b/src/channels/plugins/outbound/dingtalk.ts new file mode 100644 index 000000000000..546bd260ab53 --- /dev/null +++ b/src/channels/plugins/outbound/dingtalk.ts @@ -0,0 +1,45 @@ +import type { ChannelOutboundAdapter } from "../types.js"; +import { chunkMarkdownText } from "../../../auto-reply/chunk.js"; +import { resolveDingTalkAccount } from "../../../dingtalk/accounts.js"; +import { parseDingTalkTarget } from "../../../dingtalk/targets.js"; + +export const dingtalkOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: (text, limit) => chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + sendText: async ({ cfg, to, text, accountId }) => { + const account = resolveDingTalkAccount({ cfg, accountId }); + if (!account.config.clientId || !account.config.clientSecret) { + throw new Error("DingTalk not configured"); + } + const target = parseDingTalkTarget(to ?? ""); + const { sendDingTalkProactiveText } = await import("../../../dingtalk/send.js"); + await sendDingTalkProactiveText( + account.config, + target.type === "group" + ? { type: "group", openConversationId: target.id } + : { type: "user", userId: target.id }, + text, + ); + return { channel: "dingtalk", messageId: "unknown" }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => { + const account = resolveDingTalkAccount({ cfg, accountId }); + if (!account.config.clientId || !account.config.clientSecret) { + throw new Error("DingTalk not configured"); + } + const target = parseDingTalkTarget(to ?? ""); + const payload = mediaUrl ? mediaUrl : (text ?? ""); + const { sendDingTalkProactiveText } = await import("../../../dingtalk/send.js"); + await sendDingTalkProactiveText( + account.config, + target.type === "group" + ? { type: "group", openConversationId: target.id } + : { type: "user", userId: target.id }, + payload, + { msgType: mediaUrl ? "image" : "text" }, + ); + return { channel: "dingtalk", messageId: "unknown" }; + }, +}; diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index b6319f3a53a9..f0a602a413b2 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -1,4 +1,5 @@ import type { GroupPolicy } from "./types.base.js"; +import type { DingTalkConfig } from "./types.dingtalk.js"; import type { DiscordConfig } from "./types.discord.js"; import type { GoogleChatConfig } from "./types.googlechat.js"; import type { IMessageConfig } from "./types.imessage.js"; @@ -28,6 +29,7 @@ export type ChannelsConfig = { whatsapp?: WhatsAppConfig; telegram?: TelegramConfig; discord?: DiscordConfig; + dingtalk?: DingTalkConfig; googlechat?: GoogleChatConfig; slack?: SlackConfig; signal?: SignalConfig; diff --git a/src/config/types.dingtalk.ts b/src/config/types.dingtalk.ts new file mode 100644 index 000000000000..38690034fa3b --- /dev/null +++ b/src/config/types.dingtalk.ts @@ -0,0 +1,47 @@ +import type { DmPolicy, GroupPolicy, MarkdownConfig, OutboundRetryConfig } from "./types.base.js"; +import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; +import type { DmConfig } from "./types.messages.js"; +import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; + +export type DingTalkGroupConfig = { + requireMention?: boolean; + tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; + skills?: string[]; + enabled?: boolean; + allowFrom?: Array; + systemPrompt?: string; +}; + +export type DingTalkAccountConfig = { + name?: string; + enabled?: boolean; + clientId?: string; + clientSecret?: string; + clientSecretFile?: string; + markdown?: MarkdownConfig; + dmPolicy?: DmPolicy; + groupPolicy?: GroupPolicy; + allowFrom?: Array; + groupAllowFrom?: Array; + historyLimit?: number; + dmHistoryLimit?: number; + dms?: Record; + groups?: Record; + textChunkLimit?: number; + chunkMode?: "length" | "newline"; + blockStreaming?: boolean; + streaming?: boolean; + mediaMaxMb?: number; + responsePrefix?: string; + retry?: OutboundRetryConfig; + heartbeat?: ChannelHeartbeatVisibilityConfig; + systemPrompt?: string; + sessionTimeoutMs?: number; + aiCardTemplateId?: string; + debug?: boolean; +}; + +export type DingTalkConfig = { + accounts?: Record; +} & DingTalkAccountConfig; diff --git a/src/config/types.ts b/src/config/types.ts index d14f1178e836..8ebf7e31bbb4 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -9,6 +9,7 @@ export * from "./types.browser.js"; export * from "./types.channels.js"; export * from "./types.openclaw.js"; export * from "./types.cron.js"; +export * from "./types.dingtalk.js"; export * from "./types.discord.js"; export * from "./types.googlechat.js"; export * from "./types.gateway.js"; diff --git a/src/dingtalk/access.test.ts b/src/dingtalk/access.test.ts new file mode 100644 index 000000000000..235776268cd0 --- /dev/null +++ b/src/dingtalk/access.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { isSenderAllowed, normalizeAllowFrom } from "./access.js"; + +describe("isSenderAllowed", () => { + it("returns true for wildcard", () => { + const allow = normalizeAllowFrom(["*"]); + expect(isSenderAllowed({ allow, senderId: "user1" })).toBe(true); + }); + + it("returns false when allowlist has no entries", () => { + const allow = normalizeAllowFrom([]); + expect(isSenderAllowed({ allow, senderId: "user1" })).toBe(false); + }); + + it("returns true for matching senderId", () => { + const allow = normalizeAllowFrom(["user1", "user2"]); + expect(isSenderAllowed({ allow, senderId: "user1" })).toBe(true); + }); + + it("returns true for case-insensitive match", () => { + const allow = normalizeAllowFrom(["User1"]); + expect(isSenderAllowed({ allow, senderId: "user1" })).toBe(true); + }); + + it("returns false for non-matching senderId", () => { + const allow = normalizeAllowFrom(["user1"]); + expect(isSenderAllowed({ allow, senderId: "user2" })).toBe(false); + }); + + it("returns false when senderId is undefined", () => { + const allow = normalizeAllowFrom(["user1"]); + expect(isSenderAllowed({ allow })).toBe(false); + }); +}); diff --git a/src/dingtalk/access.ts b/src/dingtalk/access.ts new file mode 100644 index 000000000000..f9749fb3e11c --- /dev/null +++ b/src/dingtalk/access.ts @@ -0,0 +1,72 @@ +import type { AllowlistMatch } from "../channels/allowlist-match.js"; + +export type NormalizedAllowFrom = { + entries: string[]; + entriesLower: string[]; + hasWildcard: boolean; + hasEntries: boolean; +}; + +export type AllowFromMatch = AllowlistMatch<"wildcard" | "id">; + +export const normalizeAllowFrom = (list?: Array): NormalizedAllowFrom => { + const entries = (list ?? []).map((value) => String(value).trim()).filter(Boolean); + const hasWildcard = entries.includes("*"); + const normalized = entries + .filter((value) => value !== "*") + .map((value) => value.replace(/^(dingtalk|dingtalk-connector|dd|ding):/i, "")); + const normalizedLower = normalized.map((value) => value.toLowerCase()); + return { + entries: normalized, + entriesLower: normalizedLower, + hasWildcard, + hasEntries: entries.length > 0, + }; +}; + +export const normalizeAllowFromWithStore = (params: { + allowFrom?: Array; + storeAllowFrom?: string[]; +}): NormalizedAllowFrom => { + const combined = [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])] + .map((value) => String(value).trim()) + .filter(Boolean); + return normalizeAllowFrom(combined); +}; + +export const isSenderAllowed = (params: { allow: NormalizedAllowFrom; senderId?: string }) => { + const { allow, senderId } = params; + if (allow.hasWildcard) { + return true; + } + if (!allow.hasEntries) { + return false; + } + if (senderId && allow.entries.includes(senderId)) { + return true; + } + if (senderId && allow.entriesLower.includes(senderId.toLowerCase())) { + return true; + } + return false; +}; + +export const resolveSenderAllowMatch = (params: { + allow: NormalizedAllowFrom; + senderId?: string; +}): AllowFromMatch => { + const { allow, senderId } = params; + if (allow.hasWildcard) { + return { allowed: true, matchKey: "*", matchSource: "wildcard" }; + } + if (!allow.hasEntries) { + return { allowed: false }; + } + if (senderId && allow.entries.includes(senderId)) { + return { allowed: true, matchKey: senderId, matchSource: "id" }; + } + if (senderId && allow.entriesLower.includes(senderId.toLowerCase())) { + return { allowed: true, matchKey: senderId.toLowerCase(), matchSource: "id" }; + } + return { allowed: false }; +}; diff --git a/src/dingtalk/accounts.ts b/src/dingtalk/accounts.ts new file mode 100644 index 000000000000..5fcd015f1772 --- /dev/null +++ b/src/dingtalk/accounts.ts @@ -0,0 +1,125 @@ +import fs from "node:fs"; +import type { OpenClawConfig } from "../config/config.js"; +import type { DingTalkAccountConfig } from "../config/types.dingtalk.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; + +export type DingTalkTokenSource = "config" | "file" | "none"; + +export type ResolvedDingTalkAccountConfig = DingTalkAccountConfig & { + clientId: string; + clientSecret: string; +}; + +export type ResolvedDingTalkAccount = { + accountId: string; + config: ResolvedDingTalkAccountConfig; + tokenSource: DingTalkTokenSource; + name?: string; + enabled: boolean; +}; + +function readFileIfExists(filePath?: string): string | undefined { + if (!filePath) { + return undefined; + } + try { + return fs.readFileSync(filePath, "utf-8").trim(); + } catch { + return undefined; + } +} + +function resolveAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): DingTalkAccountConfig | undefined { + const accounts = cfg.channels?.dingtalk?.accounts; + if (!accounts || typeof accounts !== "object") { + return undefined; + } + const direct = accounts[accountId] as DingTalkAccountConfig | undefined; + if (direct) { + return direct; + } + const normalized = normalizeAccountId(accountId); + const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized); + return matchKey ? (accounts[matchKey] as DingTalkAccountConfig | undefined) : undefined; +} + +function mergeAccountConfig(cfg: OpenClawConfig, accountId: string): DingTalkAccountConfig { + const { accounts: _ignored, ...base } = (cfg.channels?.dingtalk ?? + {}) as DingTalkAccountConfig & { + accounts?: unknown; + }; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +function resolveClientSecret(config?: { clientSecret?: string; clientSecretFile?: string }): { + value?: string; + source?: Exclude; +} { + const direct = config?.clientSecret?.trim(); + if (direct) { + return { value: direct, source: "config" }; + } + const fromFile = readFileIfExists(config?.clientSecretFile); + if (fromFile) { + return { value: fromFile, source: "file" }; + } + return {}; +} + +export function listDingTalkAccountIds(cfg: OpenClawConfig): string[] { + const dtCfg = cfg.channels?.dingtalk; + const accounts = dtCfg?.accounts; + const ids = new Set(); + const baseConfigured = Boolean( + dtCfg?.clientId?.trim() && (dtCfg?.clientSecret?.trim() || dtCfg?.clientSecretFile), + ); + if (baseConfigured) { + ids.add(DEFAULT_ACCOUNT_ID); + } + if (accounts) { + for (const id of Object.keys(accounts)) { + ids.add(normalizeAccountId(id)); + } + } + return Array.from(ids); +} + +export function resolveDefaultDingTalkAccountId(cfg: OpenClawConfig): string { + const ids = listDingTalkAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +export function resolveDingTalkAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): ResolvedDingTalkAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = params.cfg.channels?.dingtalk?.enabled !== false; + const merged = mergeAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + + const clientId = merged.clientId?.trim() || ""; + const secretResolution = resolveClientSecret(merged); + const clientSecret = secretResolution.value ?? ""; + + let tokenSource: DingTalkTokenSource = "none"; + if (secretResolution.value) { + tokenSource = secretResolution.source ?? "config"; + } + if (!clientId || !clientSecret) { + tokenSource = "none"; + } + + const config: ResolvedDingTalkAccountConfig = { ...merged, clientId, clientSecret }; + const name = config.name?.trim() || undefined; + + return { accountId, config, tokenSource, name, enabled }; +} diff --git a/src/dingtalk/ai-card.ts b/src/dingtalk/ai-card.ts new file mode 100644 index 000000000000..301fc79ea091 --- /dev/null +++ b/src/dingtalk/ai-card.ts @@ -0,0 +1,312 @@ +import { getDingTalkAccessToken } from "./auth.js"; +import { loadDingTalkAxios } from "./deps.js"; + +const axios = loadDingTalkAxios(); + +const DINGTALK_API = "https://api.dingtalk.com"; +const DEFAULT_TEMPLATE_ID = "382e4302-551d-4880-bf29-a30acfab2e71.schema"; + +type Logger = { + info?: (message: string) => void; + warn?: (message: string) => void; + error?: (message: string) => void; +}; + +type ErrorResponse = { + status?: number; + data?: unknown; +}; + +function getErrorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + return String(err); +} + +function getErrorResponse(err: unknown): ErrorResponse | undefined { + if (typeof err !== "object" || err === null || !("response" in err)) { + return undefined; + } + const response = (err as { response?: unknown }).response; + if (typeof response !== "object" || response === null) { + return undefined; + } + const statusValue = (response as { status?: unknown }).status; + const data = (response as { data?: unknown }).data; + return { + status: typeof statusValue === "number" ? statusValue : undefined, + data, + }; +} + +export type AICardTarget = + | { type: "user"; userId: string } + | { type: "group"; openConversationId: string }; + +export type AICardInstance = { + cardInstanceId: string; + accessToken: string; + inputingStarted: boolean; +}; + +// flowStatus 值与 Python SDK AICardStatus 一致(cardParamMap 的值必须是字符串) +const AICardStatus = { + PROCESSING: "1", + INPUTING: "2", + FINISHED: "3", + EXECUTING: "4", + FAILED: "5", +} as const; + +function buildDeliverBody(cardInstanceId: string, target: AICardTarget, robotCode: string) { + 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" }, + }; +} + +export async function createAICardForTarget( + config: { clientId: string; clientSecret: string; aiCardTemplateId?: string }, + target: AICardTarget, + log?: Logger, +): Promise { + const targetDesc = + target.type === "group" ? `群聊 ${target.openConversationId}` : `用户 ${target.userId}`; + + try { + const token = await getDingTalkAccessToken(config); + const cardInstanceId = `card_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + + log?.info?.(`[DingTalk][AICard] 开始创建卡片: ${targetDesc}, outTrackId=${cardInstanceId}`); + + // 1. 创建卡片实例 + const createBody = { + cardTemplateId: config.aiCardTemplateId || DEFAULT_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: unknown) { + const errMessage = getErrorMessage(err); + const response = getErrorResponse(err); + log?.error?.(`[DingTalk][AICard] 创建卡片失败 (${targetDesc}): ${errMessage}`); + if (response) { + log?.error?.( + `[DingTalk][AICard] 错误响应: status=${response.status} data=${JSON.stringify(response.data)}`, + ); + } + return null; + } +} + +export async function streamAICard( + card: AICardInstance, + content: string, + finished: boolean = false, + log?: Logger, +): 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: unknown) { + const errMessage = getErrorMessage(err); + const response = getErrorResponse(err); + log?.error?.( + `[DingTalk][AICard] INPUTING 切换失败: ${errMessage}, resp=${JSON.stringify(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, + 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: unknown) { + const errMessage = getErrorMessage(err); + const response = getErrorResponse(err); + log?.error?.( + `[DingTalk][AICard] streaming 更新失败: ${errMessage}, resp=${JSON.stringify(response?.data)}`, + ); + throw err; + } +} + +// 完成 AI Card:先 streaming isFinalize 关闭流式通道,再 put_card_data 更新 FINISHED 状态 +export async function finishAICard( + card: AICardInstance, + content: string, + log?: Logger, +): 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: unknown) { + const errMessage = getErrorMessage(err); + const response = getErrorResponse(err); + log?.error?.( + `[DingTalk][AICard] FINISHED 更新失败: ${errMessage}, resp=${JSON.stringify(response?.data)}`, + ); + } +} + +export class DingTalkStreamingSession { + private readonly config: { clientId: string; clientSecret: string; aiCardTemplateId?: string }; + private readonly _target: AICardTarget; + private card: AICardInstance | null = null; + + constructor( + config: { clientId: string; clientSecret: string; aiCardTemplateId?: string }, + params: { isGroup: boolean; senderId: string; chatId: string }, + ) { + this.config = config; + this._target = params.isGroup + ? { type: "group", openConversationId: params.chatId } + : { type: "user", userId: params.senderId }; + } + + get target(): AICardTarget { + return this._target; + } + + isActive(): boolean { + return Boolean(this.card); + } + + async start(log?: Logger): Promise { + if (this.card) { + return; + } + const card = await createAICardForTarget(this.config, this._target, log); + if (!card) { + throw new Error("Failed to create DingTalk AI Card"); + } + this.card = card; + } + + async update(content: string, log?: Logger): Promise { + if (!this.card) { + return; + } + await streamAICard(this.card, content, false, log); + } + + async close(content: string, log?: Logger): Promise { + if (!this.card) { + return; + } + await finishAICard(this.card, content, log); + this.card = null; + } +} diff --git a/src/dingtalk/auth.ts b/src/dingtalk/auth.ts new file mode 100644 index 000000000000..6f8941e3944a --- /dev/null +++ b/src/dingtalk/auth.ts @@ -0,0 +1,65 @@ +import { loadDingTalkAxios } from "./deps.js"; + +const axios = loadDingTalkAxios(); + +const tokenCache = new Map(); + +function toRecord(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + return value as Record; +} + +function getStringField(data: unknown, field: string): string | undefined { + const value = toRecord(data)?.[field]; + return typeof value === "string" ? value : undefined; +} + +function getNumberField(data: unknown, field: string): number | undefined { + const value = toRecord(data)?.[field]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +export async function getDingTalkAccessToken(config: { + clientId: string; + clientSecret: string; +}): Promise { + const now = Date.now(); + const cached = tokenCache.get(config.clientId); + if (cached && cached.expiry > now + 60_000) { + return cached.accessToken; + } + const response = await axios.post("https://api.dingtalk.com/v1.0/oauth2/accessToken", { + appKey: config.clientId, + appSecret: config.clientSecret, + }); + const accessToken = getStringField(response.data, "accessToken"); + const expireIn = getNumberField(response.data, "expireIn"); + if (!accessToken || !expireIn) { + throw new Error("Invalid DingTalk access token response"); + } + tokenCache.set(config.clientId, { + accessToken, + expiry: now + expireIn * 1000, + }); + return accessToken; +} + +export async function getDingTalkOapiToken(config: { + clientId: string; + clientSecret: string; +}): Promise { + try { + const resp = await axios.get("https://oapi.dingtalk.com/gettoken", { + params: { appkey: config.clientId, appsecret: config.clientSecret }, + }); + const errcode = getNumberField(resp.data, "errcode"); + if (errcode === 0) { + return getStringField(resp.data, "access_token") ?? null; + } + return null; + } catch { + return null; + } +} diff --git a/src/dingtalk/config.ts b/src/dingtalk/config.ts new file mode 100644 index 000000000000..12f45deab7b7 --- /dev/null +++ b/src/dingtalk/config.ts @@ -0,0 +1,93 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { DmPolicy, GroupPolicy } from "../config/types.base.js"; +import type { DingTalkGroupConfig } from "../config/types.dingtalk.js"; + +const firstDefined = (...values: Array) => { + for (const value of values) { + if (typeof value !== "undefined") { + return value; + } + } + return undefined; +}; + +export type ResolvedDingTalkConfig = { + enabled: boolean; + dmPolicy: DmPolicy; + groupPolicy: GroupPolicy; + allowFrom: string[]; + groupAllowFrom: string[]; + historyLimit: number; + dmHistoryLimit: number; + textChunkLimit: number; + chunkMode: "length" | "newline"; + blockStreaming: boolean; + streaming: boolean; + mediaMaxMb: number; + enableMediaUpload: boolean; + systemPrompt: string; + sessionTimeoutMs: number; + aiCardTemplateId?: string; + groups: Record; +}; + +export function resolveDingTalkConfig(params: { + cfg: OpenClawConfig; + accountId?: string; +}): ResolvedDingTalkConfig { + const { cfg, accountId } = params; + const dtCfg = cfg.channels?.dingtalk; + const accountCfg = accountId ? dtCfg?.accounts?.[accountId] : undefined; + const defaults = cfg.channels?.defaults; + + return { + enabled: firstDefined(accountCfg?.enabled, dtCfg?.enabled, true) ?? true, + dmPolicy: firstDefined(accountCfg?.dmPolicy, dtCfg?.dmPolicy) ?? "open", + groupPolicy: + firstDefined(accountCfg?.groupPolicy, dtCfg?.groupPolicy, defaults?.groupPolicy) ?? "open", + allowFrom: (accountCfg?.allowFrom ?? dtCfg?.allowFrom ?? []).map(String), + groupAllowFrom: (accountCfg?.groupAllowFrom ?? dtCfg?.groupAllowFrom ?? []).map(String), + historyLimit: firstDefined(accountCfg?.historyLimit, dtCfg?.historyLimit) ?? 10, + dmHistoryLimit: firstDefined(accountCfg?.dmHistoryLimit, dtCfg?.dmHistoryLimit) ?? 20, + textChunkLimit: firstDefined(accountCfg?.textChunkLimit, dtCfg?.textChunkLimit) ?? 4000, + chunkMode: firstDefined(accountCfg?.chunkMode, dtCfg?.chunkMode) ?? "length", + blockStreaming: firstDefined(accountCfg?.blockStreaming, dtCfg?.blockStreaming) ?? false, + streaming: firstDefined(accountCfg?.streaming, dtCfg?.streaming) ?? true, + mediaMaxMb: firstDefined(accountCfg?.mediaMaxMb, dtCfg?.mediaMaxMb) ?? 20, + // Media upload is always enabled for DingTalk; disable toggle removed. + enableMediaUpload: true, + systemPrompt: firstDefined(accountCfg?.systemPrompt, dtCfg?.systemPrompt) ?? "", + sessionTimeoutMs: + firstDefined(accountCfg?.sessionTimeoutMs, dtCfg?.sessionTimeoutMs) ?? 30 * 60 * 1000, + aiCardTemplateId: firstDefined(accountCfg?.aiCardTemplateId, dtCfg?.aiCardTemplateId), + groups: { ...dtCfg?.groups, ...accountCfg?.groups }, + }; +} + +export function resolveDingTalkGroupConfig(params: { + cfg: OpenClawConfig; + accountId?: string; + chatId: string; +}): { groupConfig?: DingTalkGroupConfig } { + const resolved = resolveDingTalkConfig({ cfg: params.cfg, accountId: params.accountId }); + const groupConfig = resolved.groups[params.chatId]; + return { groupConfig }; +} + +export function resolveDingTalkGroupRequireMention(params: { + cfg: OpenClawConfig; + accountId?: string; + chatId: string; +}): boolean { + const { groupConfig } = resolveDingTalkGroupConfig(params); + return groupConfig?.requireMention ?? true; +} + +export function resolveDingTalkGroupEnabled(params: { + cfg: OpenClawConfig; + accountId?: string; + chatId: string; +}): boolean { + const { groupConfig } = resolveDingTalkGroupConfig(params); + return groupConfig?.enabled ?? true; +} diff --git a/src/dingtalk/deps.ts b/src/dingtalk/deps.ts new file mode 100644 index 000000000000..e275b0534112 --- /dev/null +++ b/src/dingtalk/deps.ts @@ -0,0 +1,94 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +type AxiosResponseLike = { + data: unknown; + status?: number; +}; + +type AxiosClientLike = { + get: (url: string, config?: Record) => Promise; + post: ( + url: string, + data?: unknown, + config?: Record, + ) => Promise; + put: ( + url: string, + data?: unknown, + config?: Record, + ) => Promise; +}; + +type FormDataLike = { + append: (name: string, value: unknown, options?: Record) => void; + getHeaders: () => Record; +}; + +type FormDataCtor = new () => FormDataLike; + +type FfmpegInstallerLike = { + path: string; +}; + +type DingTalkStreamClient = { + registerCallbackListener: (topic: string, callback: (payload: unknown) => Promise) => void; + socketCallBackResponse: (messageId: string, body: { success: boolean }) => void; + connect: () => Promise; + disconnect: () => void; +}; + +type DingTalkStreamModuleLike = { + DWClient: new (options: { + clientId: string; + clientSecret: string; + debug?: boolean; + }) => DingTalkStreamClient; + TOPIC_ROBOT: string; +}; + +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +const pluginPackageJsonPath = path.resolve(currentDir, "../../extensions/dingtalk/package.json"); + +if (!fs.existsSync(pluginPackageJsonPath)) { + throw new Error( + "DingTalk plugin package is missing. Ensure extensions/dingtalk exists before enabling DingTalk.", + ); +} + +const pluginRequire = createRequire(pluginPackageJsonPath); + +function unwrapDefault(mod: unknown): T { + if (mod && typeof mod === "object" && "default" in mod) { + return (mod as { default: T }).default; + } + return mod as T; +} + +function requireDingTalkDependency(name: string): T { + try { + return pluginRequire(name) as T; + } catch { + throw new Error( + `DingTalk dependency "${name}" is missing. Run npm install --omit=dev in extensions/dingtalk.`, + ); + } +} + +export function loadDingTalkAxios(): AxiosClientLike { + return unwrapDefault(requireDingTalkDependency("axios")); +} + +export function loadDingTalkFormData(): FormDataCtor { + return unwrapDefault(requireDingTalkDependency("form-data")); +} + +export function loadFfmpegInstaller(): FfmpegInstallerLike { + return unwrapDefault(requireDingTalkDependency("@ffmpeg-installer/ffmpeg")); +} + +export function loadDingTalkStreamModule(): DingTalkStreamModuleLike { + return requireDingTalkDependency("dingtalk-stream"); +} diff --git a/src/dingtalk/directory-store.ts b/src/dingtalk/directory-store.ts new file mode 100644 index 000000000000..c71af901df1e --- /dev/null +++ b/src/dingtalk/directory-store.ts @@ -0,0 +1,169 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import lockfile from "proper-lockfile"; +import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; + +const STORE_LOCK_OPTIONS = { + retries: { + retries: 10, + factor: 2, + minTimeout: 100, + maxTimeout: 10_000, + randomize: true, + }, + stale: 30_000, +} as const; + +const KNOWN_USERS_FILENAME = "dingtalk-known-users.json"; + +export type DingTalkKnownUser = { + userId: string; + name?: string; + lastSeenAt: string; +}; + +type DingTalkKnownUsersStore = { + version: 1; + users: DingTalkKnownUser[]; +}; + +function resolveKnownUsersPath(env: NodeJS.ProcessEnv = process.env): string { + const stateDir = resolveStateDir(env, os.homedir); + const oauthDir = resolveOAuthDir(env, stateDir); + return path.join(oauthDir, KNOWN_USERS_FILENAME); +} + +function safeParseJson(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +async function readStore(filePath: string): Promise { + try { + const raw = await fs.promises.readFile(filePath, "utf-8"); + const parsed = safeParseJson(raw); + if (!parsed || !Array.isArray(parsed.users)) { + return { version: 1, users: [] }; + } + return { + version: 1, + users: parsed.users + .filter((item) => item && typeof item.userId === "string") + .map((item) => ({ + userId: normalizeUserId(item.userId), + name: normalizeName(item.name), + lastSeenAt: + typeof item.lastSeenAt === "string" && item.lastSeenAt.trim() + ? item.lastSeenAt + : new Date(0).toISOString(), + })) + .filter((item) => Boolean(item.userId)), + }; + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") { + return { version: 1, users: [] }; + } + return { version: 1, users: [] }; + } +} + +async function writeStore(filePath: string, value: DingTalkKnownUsersStore): Promise { + const dir = path.dirname(filePath); + await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); + const tmp = path.join(dir, `${path.basename(filePath)}.${Date.now()}.tmp`); + await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf-8"); + await fs.promises.chmod(tmp, 0o600); + await fs.promises.rename(tmp, filePath); +} + +async function ensureStoreFile(filePath: string): Promise { + try { + await fs.promises.access(filePath); + } catch { + await writeStore(filePath, { version: 1, users: [] }); + } +} + +async function withStoreLock(filePath: string, fn: () => Promise): Promise { + await ensureStoreFile(filePath); + let release: (() => Promise) | undefined; + try { + release = await lockfile.lock(filePath, STORE_LOCK_OPTIONS); + return await fn(); + } finally { + if (release) { + try { + await release(); + } catch { + // ignore unlock errors + } + } + } +} + +function normalizeUserId(value: string | number): string { + return String(value) + .trim() + .replace(/^(dingtalk|dingtalk-connector|dd|ding):/i, ""); +} + +function normalizeName(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +export async function readDingTalkKnownUsers( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const filePath = resolveKnownUsersPath(env); + const store = await readStore(filePath); + return store.users.toSorted((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt)); +} + +export async function upsertDingTalkKnownUser(params: { + userId: string | number; + name?: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ changed: boolean; entry: DingTalkKnownUser | null }> { + const env = params.env ?? process.env; + const userId = normalizeUserId(params.userId); + if (!userId) { + return { changed: false, entry: null }; + } + const name = normalizeName(params.name); + const filePath = resolveKnownUsersPath(env); + return await withStoreLock(filePath, async () => { + const store = await readStore(filePath); + const now = new Date().toISOString(); + const idx = store.users.findIndex((item) => item.userId === userId); + if (idx >= 0) { + const existing = store.users[idx]; + if (!existing) { + return { changed: false, entry: null }; + } + const next: DingTalkKnownUser = { + userId, + name: name ?? existing.name, + lastSeenAt: now, + }; + const changed = next.name !== existing.name || next.lastSeenAt !== existing.lastSeenAt; + if (changed) { + store.users[idx] = next; + await writeStore(filePath, { version: 1, users: store.users }); + } + return { changed, entry: next }; + } + const entry: DingTalkKnownUser = { userId, name, lastSeenAt: now }; + store.users.push(entry); + await writeStore(filePath, { version: 1, users: store.users }); + return { changed: true, entry }; + }); +} diff --git a/src/dingtalk/index.ts b/src/dingtalk/index.ts new file mode 100644 index 000000000000..7b2091bb0a99 --- /dev/null +++ b/src/dingtalk/index.ts @@ -0,0 +1,20 @@ +export * from "./accounts.js"; +export * from "./config.js"; +export * from "./access.js"; +export * from "./pairing-store.js"; +export * from "./directory-store.js"; +export * from "./targets.js"; +export * from "../channels/plugins/outbound/dingtalk.js"; + +type MonitorDingTalkProvider = typeof import("./monitor.js").monitorDingTalkProvider; +type ProbeDingTalk = typeof import("./probe.js").probeDingTalk; + +export async function monitorDingTalkProvider(...args: Parameters) { + const mod = await import("./monitor.js"); + return mod.monitorDingTalkProvider(...args); +} + +export async function probeDingTalk(...args: Parameters) { + const mod = await import("./probe.js"); + return mod.probeDingTalk(...args); +} diff --git a/src/dingtalk/media.ts b/src/dingtalk/media.ts new file mode 100644 index 000000000000..f7b01ce093f6 --- /dev/null +++ b/src/dingtalk/media.ts @@ -0,0 +1,1187 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { AICardTarget } from "./ai-card.js"; +import { getDingTalkAccessToken } from "./auth.js"; +import { loadDingTalkAxios, loadDingTalkFormData, loadFfmpegInstaller } from "./deps.js"; + +const ffmpegInstaller = loadFfmpegInstaller(); +const axios = loadDingTalkAxios(); +const FormData = loadDingTalkFormData(); + +/** + * 匹配 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; + +/** 图片文件扩展名 */ +export 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 value = raw; + if (value.startsWith("file://")) { + value = value.replace("file://", ""); + } else if (value.startsWith("MEDIA:")) { + value = value.replace("MEDIA:", ""); + } else if (value.startsWith("attachment://")) { + value = value.replace("attachment://", ""); + } + + // 解码 URL 编码的路径(如中文字符 %E5%9B%BE → 图) + try { + value = decodeURIComponent(value); + } catch { + // 解码失败则保持原样 + } + return value; +} + +export const FILE_MARKER_PATTERN = /\[DINGTALK_FILE\]({.*?})\[\/DINGTALK_FILE\]/g; +export const VIDEO_MARKER_PATTERN = /\[DINGTALK_VIDEO\]({.*?})\[\/DINGTALK_VIDEO\]/g; +export const AUDIO_MARKER_PATTERN = /\[DINGTALK_AUDIO\]({.*?})\[\/DINGTALK_AUDIO\]/g; + +/** 视频大小限制:20MB */ +const MAX_VIDEO_SIZE = 20 * 1024 * 1024; +/** 文件大小限制:20MB(字节) */ +const MAX_FILE_SIZE = 20 * 1024 * 1024; + +/** 音频文件扩展名 */ +const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "amr", "ogg", "aac", "flac", "m4a"]); + +/** 判断是否为音频文件 */ +function isAudioFile(fileType: string): boolean { + return AUDIO_EXTENSIONS.has(fileType.toLowerCase()); +} + +type CommandResult = { + stdout: string; + stderr: string; + code: number | null; +}; + +type Logger = { + info?: (message: string) => void; + warn?: (message: string) => void; + error?: (message: string) => void; +}; + +function getErrorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + return String(err); +} + +function toRecord(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + return value as Record; +} + +function getStringField(data: unknown, field: string): string | undefined { + const value = toRecord(data)?.[field]; + return typeof value === "string" ? value : undefined; +} + +function getBooleanField(data: unknown, field: string): boolean | undefined { + const value = toRecord(data)?.[field]; + return typeof value === "boolean" ? value : undefined; +} + +function resolveFfprobePath(ffmpegPath: string): string { + return ffmpegPath.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1"); +} + +async function runCommand( + command: string, + args: string[], + timeoutMs: number, +): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, timeoutMs); + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + child.on("close", (code) => { + clearTimeout(timer); + if (timedOut) { + reject(new Error(`Command timeout: ${command}`)); + return; + } + resolve({ stdout, stderr, code }); + }); + }); +} + +/** 视频信息接口 */ +type VideoInfo = { + path: string; +}; + +/** 视频元数据接口 */ +type VideoMetadata = { + duration: number; + width: number; + height: number; +}; + +/** 文件信息接口 */ +type FileInfo = { + path: string; + fileName: string; + fileType: string; +}; + +/** 音频信息接口 */ +type AudioInfo = { + path: string; +}; + +export function buildDingTalkMediaSystemPrompt(): 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,超过限制时告知用户文件过大。`; +} + +/** + * 通用媒体文件上传函数 + * @param filePath 文件路径 + * @param mediaType 媒体类型:image, file, video, voice + * @param oapiToken 钉钉 access_token + * @param maxSize 最大文件大小(字节),默认 20MB + * @param log 日志对象 + * @returns media_id 或 null + */ +export async function uploadMediaToDingTalk( + filePath: string, + mediaType: "image" | "file" | "video" | "voice", + oapiToken: string, + maxSize: number = 20 * 1024 * 1024, + log?: Logger, +): Promise { + try { + 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 = getStringField(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: unknown) { + log?.error?.(`[DingTalk][${mediaType}] 上传失败: ${getErrorMessage(err)}`); + return null; + } +} + +/** 扫描内容中的本地图片路径,上传到钉钉并替换为 media_id */ +export async function processLocalImages( + content: string, + oapiToken: string | null, + log?: Logger, +): 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((match) => { + // 检查这个路径是否已经在 ![...](...) 中 + const idx = match.index ?? 0; + 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.toReversed()) { + 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})`; + const startIndex = match.index ?? 0; + result = + result.slice(0, startIndex) + result.slice(startIndex).replace(fullMatch, replacement); + log?.info?.(`[DingTalk][Media] 替换纯文本路径为图片: ${replacement}`); + } + } + } + + if (mdMatches.length === 0 && newBareMatches.length === 0) { + log?.info?.(`[DingTalk][Media] 未检测到本地图片路径`); + } + + return result; +} + +/** + * 提取视频元数据(时长、分辨率) + */ +async function extractVideoMetadata(filePath: string, log?: Logger): Promise { + try { + const ffmpegPath = ffmpegInstaller.path; + const ffprobePath = resolveFfprobePath(ffmpegPath); + const { stdout, stderr, code } = await runCommand( + ffprobePath, + ["-v", "error", "-print_format", "json", "-show_streams", "-show_format", filePath], + 15_000, + ); + if (code !== 0) { + log?.error?.(`[DingTalk][Video] ffprobe 失败: ${stderr.trim() || "unknown error"}`); + return null; + } + + let metadata: unknown; + try { + metadata = JSON.parse(stdout); + } catch (err: unknown) { + log?.error?.(`[DingTalk][Video] 解析元数据失败: ${getErrorMessage(err)}`); + return null; + } + + const metadataRecord = toRecord(metadata); + const streams = Array.isArray(metadataRecord?.streams) ? metadataRecord.streams : []; + const videoStream = streams.find((stream) => { + const streamRecord = toRecord(stream); + return streamRecord?.codec_type === "video"; + }); + const videoStreamRecord = toRecord(videoStream); + if (!videoStreamRecord) { + log?.warn?.(`[DingTalk][Video] 未找到视频流`); + return null; + } + + const formatRecord = toRecord(metadataRecord?.format); + const durationRaw = Number(formatRecord?.duration ?? videoStreamRecord.duration ?? 0); + const duration = Number.isFinite(durationRaw) ? Math.floor(durationRaw) : 0; + const width = Number(videoStreamRecord.width ?? 0) || 0; + const height = Number(videoStreamRecord.height ?? 0) || 0; + + log?.info?.(`[DingTalk][Video] 元数据: duration=${duration}s, ${width}x${height}`); + return { duration, width, height }; + } catch (err: unknown) { + log?.error?.(`[DingTalk][Video] ffprobe 失败: ${getErrorMessage(err)}`); + return null; + } +} + +/** + * 生成视频封面图(第1秒截图) + */ +async function extractVideoThumbnail( + videoPath: string, + outputPath: string, + log?: Logger, +): Promise { + try { + const ffmpegPath = ffmpegInstaller.path; + const { stderr, code } = await runCommand( + ffmpegPath, + [ + "-y", + "-loglevel", + "error", + "-ss", + "1", + "-i", + videoPath, + "-frames:v", + "1", + "-vf", + "scale=-1:360", + outputPath, + ], + 30_000, + ); + if (code !== 0) { + log?.error?.(`[DingTalk][Video] 封面生成失败: ${stderr.trim() || "unknown error"}`); + return null; + } + log?.info?.(`[DingTalk][Video] 封面生成成功: ${outputPath}`); + return outputPath; + } catch (err: unknown) { + log?.error?.(`[DingTalk][Video] ffmpeg 失败: ${getErrorMessage(err)}`); + return null; + } +} + +/** + * 发送视频消息到钉钉 + */ +async function sendVideoMessage( + _config: { clientId: string; clientSecret: string }, + sessionWebhook: string, + videoInfo: VideoInfo, + videoMediaId: string, + picMediaId: string, + metadata: VideoMetadata, + oapiToken: string, + log?: Logger, +): Promise { + try { + const fileName = path.basename(videoInfo.path); + + const payload = { + msgtype: "video", + video: { + duration: metadata.duration.toString(), + videoMediaId, + videoType: "mp4", + 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 (getBooleanField(resp.data, "success") !== false) { + log?.info?.(`[DingTalk][Video] 视频消息发送成功: ${fileName}`); + } else { + log?.error?.(`[DingTalk][Video] 视频消息发送失败: ${JSON.stringify(resp.data)}`); + } + } catch (err: unknown) { + log?.error?.(`[DingTalk][Video] 发送失败: ${getErrorMessage(err)}`); + } +} + +/** + * 视频后处理主函数 + * 返回移除标记后的内容,并附带视频处理的状态提示 + * + * @param useProactiveApi 是否使用主动消息 API(用于 AI Card 场景) + * @param target 主动 API 需要的目标信息(useProactiveApi=true 时必须提供) + */ +export async function processVideoMarkers( + content: string, + sessionWebhook: string, + config: { clientId: string; clientSecret: string }, + oapiToken: string | null, + log?: Logger, + useProactiveApi: boolean = false, + target?: AICardTarget, +): Promise { + const logPrefix = useProactiveApi ? "[DingTalk][Video][Proactive]" : "[DingTalk][Video]"; + + if (!oapiToken) { + log?.warn?.(`${logPrefix} 无 oapiToken,跳过视频处理`); + return content; + } + + // 提取视频标记 + 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: unknown) { + log?.warn?.(`${logPrefix} 解析标记失败: ${getErrorMessage(err)}`); + } + } + + 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: unknown) { + log?.error?.(`${logPrefix} 处理视频失败: ${getErrorMessage(err)}`); + statusMessages.push(`⚠️ 视频处理异常: ${fileName}(${getErrorMessage(err)})`); + } 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; +} + +/** + * 从内容中提取文件标记 + * @returns { cleanedContent, fileInfos } + */ +function extractFileMarkers( + content: string, + log?: Logger, +): { 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: unknown) { + log?.warn?.(`[DingTalk][File] 解析文件标记失败: ${match[1]}, 错误: ${getErrorMessage(err)}`); + } + } + + // 移除文件标记,返回清理后的内容 + const cleanedContent = content.replace(FILE_MARKER_PATTERN, "").trim(); + return { cleanedContent, fileInfos }; +} + +/** + * 发送文件消息到钉钉 + */ +async function sendFileMessage( + _config: { clientId: string; clientSecret: string }, + sessionWebhook: string, + fileInfo: FileInfo, + mediaId: string, + oapiToken: string, + log?: Logger, +): Promise { + try { + const fileMessage = { + msgtype: "file", + file: { + 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 (getBooleanField(resp.data, "success") !== false) { + log?.info?.(`[DingTalk][File] 文件消息发送成功: ${fileInfo.fileName}`); + } else { + log?.error?.(`[DingTalk][File] 文件消息发送失败: ${JSON.stringify(resp.data)}`); + } + } catch (err: unknown) { + log?.error?.( + `[DingTalk][File] 发送文件消息异常: ${fileInfo.fileName}, 错误: ${getErrorMessage(err)}`, + ); + } +} + +/** + * 发送音频消息到钉钉(被动回复场景) + */ +async function sendAudioMessage( + _config: { clientId: string; clientSecret: string }, + sessionWebhook: string, + fileInfo: FileInfo, + mediaId: string, + oapiToken: string, + log?: Logger, +): Promise { + try { + // 钉钉语音消息格式 + const audioMessage = { + msgtype: "voice", + voice: { + mediaId, + duration: "60000", // 默认时长,单位毫秒 + }, + }; + + 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 (getBooleanField(resp.data, "success") !== false) { + log?.info?.(`[DingTalk][Audio] 语音消息发送成功: ${fileInfo.fileName}`); + } else { + log?.error?.(`[DingTalk][Audio] 语音消息发送失败: ${JSON.stringify(resp.data)}`); + } + } catch (err: unknown) { + log?.error?.( + `[DingTalk][Audio] 发送语音消息异常: ${fileInfo.fileName}, 错误: ${getErrorMessage(err)}`, + ); + } +} + +/** + * 处理文件标记:提取、上传、发送独立消息 + * 返回移除标记后的内容,并附带文件处理的状态提示 + * + * @param useProactiveApi 是否使用主动消息 API(用于 AI Card 场景,避免 sessionWebhook 失效问题) + * @param target 主动 API 需要的目标信息(useProactiveApi=true 时必须提供) + */ +export async function processFileMarkers( + content: string, + sessionWebhook: string, + config: { clientId: string; clientSecret: string }, + oapiToken: string | null, + log?: Logger, + 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[] = []; + + // 逐个上传并发送文件消息 + 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) { + if (useProactiveApi && target) { + // 使用主动消息 API(适用于 AI Card 场景) + await sendAudioProactive(config, target, fileInfo, mediaId, log); + } else { + // 使用 sessionWebhook(传统被动回复场景) + await sendAudioMessage(config, sessionWebhook, fileInfo, mediaId, oapiToken, log); + } + 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; +} + +/** + * 主动发送文件消息(使用普通消息 API) + */ +export async function sendFileProactive( + config: { clientId: string; clientSecret: string }, + target: AICardTarget, + fileInfo: FileInfo, + mediaId: string, + log?: Logger, +): Promise { + try { + const token = await getDingTalkAccessToken(config); + + // 钉钉普通消息 API 的文件消息格式 + const msgParam = { + mediaId, + fileName: fileInfo.fileName, + fileType: fileInfo.fileType, + }; + + const body: Record = { + robotCode: config.clientId, + msgKey: "sampleFile", + msgParam: JSON.stringify(msgParam), + }; + + let endpoint: string; + if (target.type === "group") { + body.openConversationId = target.openConversationId; + endpoint = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"; + } else { + body.userIds = [target.userId]; + endpoint = "https://api.dingtalk.com/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 (getStringField(resp.data, "processQueryKey")) { + log?.info?.(`[DingTalk][File][Proactive] 文件消息发送成功: ${fileInfo.fileName}`); + } else { + log?.warn?.(`[DingTalk][File][Proactive] 文件消息发送响应异常: ${JSON.stringify(resp.data)}`); + } + } catch (err: unknown) { + log?.error?.( + `[DingTalk][File][Proactive] 发送文件消息失败: ${fileInfo.fileName}, 错误: ${getErrorMessage(err)}`, + ); + } +} + +/** + * 主动发送音频消息(使用普通消息 API) + */ +export async function sendAudioProactive( + config: { clientId: string; clientSecret: string }, + target: AICardTarget, + fileInfo: FileInfo, + mediaId: string, + log?: Logger, +): Promise { + try { + const token = await getDingTalkAccessToken(config); + + // 钉钉普通消息 API 的音频消息格式 + const msgParam = { + mediaId, + duration: "60000", // 默认时长,单位毫秒 + }; + + const body: Record = { + robotCode: config.clientId, + msgKey: "sampleAudio", + msgParam: JSON.stringify(msgParam), + }; + + let endpoint: string; + if (target.type === "group") { + body.openConversationId = target.openConversationId; + endpoint = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"; + } else { + body.userIds = [target.userId]; + endpoint = "https://api.dingtalk.com/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 (getStringField(resp.data, "processQueryKey")) { + log?.info?.(`[DingTalk][Audio][Proactive] 音频消息发送成功: ${fileInfo.fileName}`); + } else { + log?.warn?.( + `[DingTalk][Audio][Proactive] 音频消息发送响应异常: ${JSON.stringify(resp.data)}`, + ); + } + } catch (err: unknown) { + log?.error?.( + `[DingTalk][Audio][Proactive] 发送音频消息失败: ${fileInfo.fileName}, 错误: ${getErrorMessage(err)}`, + ); + } +} + +/** + * 主动发送视频消息(使用普通消息 API) + */ +export async function sendVideoProactive( + config: { clientId: string; clientSecret: string }, + target: AICardTarget, + videoMediaId: string, + picMediaId: string, + metadata: VideoMetadata, + log?: Logger, +): Promise { + try { + const token = await getDingTalkAccessToken(config); + + // 钉钉普通消息 API 的视频消息格式 + const msgParam = { + duration: metadata.duration.toString(), + videoMediaId, + videoType: "mp4", + picMediaId, + }; + + const body: Record = { + robotCode: config.clientId, + msgKey: "sampleVideo", + msgParam: JSON.stringify(msgParam), + }; + + let endpoint: string; + if (target.type === "group") { + body.openConversationId = target.openConversationId; + endpoint = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"; + } else { + body.userIds = [target.userId]; + endpoint = "https://api.dingtalk.com/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 (getStringField(resp.data, "processQueryKey")) { + log?.info?.(`[DingTalk][Video][Proactive] 视频消息发送成功`); + } else { + log?.warn?.( + `[DingTalk][Video][Proactive] 视频消息发送响应异常: ${JSON.stringify(resp.data)}`, + ); + } + } catch (err: unknown) { + log?.error?.(`[DingTalk][Video][Proactive] 发送视频消息失败: ${getErrorMessage(err)}`); + } +} + +/** + * 提取音频标记并发送音频消息 + * 解析 [DINGTALK_AUDIO]{"path":"..."}[/DINGTALK_AUDIO] 标记 + * + * @param useProactiveApi 是否使用主动消息 API(用于 AI Card 场景) + * @param target 主动 API 需要的目标信息(useProactiveApi=true 时必须提供) + */ +export async function processAudioMarkers( + content: string, + sessionWebhook: string, + config: { clientId: string; clientSecret: string }, + oapiToken: string | null, + log?: Logger, + useProactiveApi: boolean = false, + target?: AICardTarget, +): Promise { + const logPrefix = useProactiveApi ? "[DingTalk][Audio][Proactive]" : "[DingTalk][Audio]"; + + if (!oapiToken) { + log?.warn?.(`${logPrefix} 无 oapiToken,跳过音频处理`); + return content; + } + + 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: unknown) { + log?.warn?.(`${logPrefix} 解析标记失败: ${getErrorMessage(err)}`); + } + } + + 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, + fileType: ext, + }; + + // 上传音频到钉钉 + const mediaId = await uploadMediaToDingTalk( + audioInfo.path, + "voice", + oapiToken, + 20 * 1024 * 1024, + log, + ); + if (!mediaId) { + statusMessages.push(`⚠️ 音频上传失败: ${fileName}(文件可能超过 20MB 限制)`); + continue; + } + + // 发送音频消息 + if (useProactiveApi && target) { + await sendAudioProactive(config, target, fileInfo, mediaId, log); + } else { + await sendAudioMessage(config, sessionWebhook, fileInfo, mediaId, oapiToken, log); + } + statusMessages.push(`✅ 音频已发送: ${fileName}`); + log?.info?.(`${logPrefix} 音频处理完成: ${fileName}`); + } catch (err: unknown) { + log?.error?.(`${logPrefix} 处理音频失败: ${getErrorMessage(err)}`); + statusMessages.push(`⚠️ 音频处理异常: ${fileName}(${getErrorMessage(err)})`); + } + } + + if (statusMessages.length > 0) { + const statusText = statusMessages.join("\n"); + cleanedContent = cleanedContent ? `${cleanedContent}\n\n${statusText}` : statusText; + } + + return cleanedContent; +} diff --git a/src/dingtalk/message.ts b/src/dingtalk/message.ts new file mode 100644 index 000000000000..c5c2144c6044 --- /dev/null +++ b/src/dingtalk/message.ts @@ -0,0 +1,389 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { resolveSessionAgentId } from "../agents/agent-scope.js"; +import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; +import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; +import { recordInboundSession } from "../channels/session.js"; +import { resolveStorePath } from "../config/sessions.js"; +import { logVerbose } from "../globals.js"; +import { getChildLogger } from "../logging.js"; +import { buildAgentSessionKey } from "../routing/resolve-route.js"; +import { isSenderAllowed, normalizeAllowFromWithStore, resolveSenderAllowMatch } from "./access.js"; +import { resolveDingTalkAccount } from "./accounts.js"; +import { DingTalkStreamingSession } from "./ai-card.js"; +import { getDingTalkOapiToken } from "./auth.js"; +import { + resolveDingTalkConfig, + resolveDingTalkGroupConfig, + resolveDingTalkGroupEnabled, +} from "./config.js"; +import { upsertDingTalkKnownUser } from "./directory-store.js"; +import { + buildDingTalkMediaSystemPrompt, + processAudioMarkers, + processFileMarkers, + processLocalImages, + processVideoMarkers, +} from "./media.js"; +import { readDingTalkAllowFromStore, upsertDingTalkPairingRequest } from "./pairing-store.js"; +import { sendDingTalkWebhookText } from "./send.js"; + +const logger = getChildLogger({ module: "dingtalk-message" }); + +/** Loosely-typed DingTalk webhook payload (shape varies by message type). */ +type DingTalkPayload = Record & { + msgtype?: string; + msgType?: string; + text?: { content?: string }; + content?: { + richText?: Array<{ type?: string; text?: string }>; + recognition?: string; + fileName?: string; + }; + conversationType?: string; + senderStaffId?: string; + senderId?: string; + senderNick?: string; + conversationId?: string; + sessionWebhook?: string; + isInAtList?: boolean; + chatbotUserId?: string; + atUsers?: Array<{ userId?: string }>; + msgId?: string; + messageId?: string; +}; + +/** Simplified logger interface compatible with DingTalk message processing. */ +type DingTalkLogger = { + info?: (message: string) => void; + warn?: (message: string) => void; + error?: (message: string) => void; +}; + +function extractDingTalkContent(data: DingTalkPayload): { text: string; messageType: string } { + const msgtype = data.msgtype || data.msgType || "text"; + switch (msgtype) { + case "text": + return { text: data.text?.content?.trim() || "", messageType: "text" }; + case "richText": { + const parts = data.content?.richText || []; + const text = parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""); + return { text: text || "[富文本消息]", messageType: "richText" }; + } + case "picture": + return { text: "[图片]", messageType: "picture" }; + case "audio": + return { text: data.content?.recognition || "[语音消息]", messageType: "audio" }; + case "video": + return { text: "[视频]", messageType: "video" }; + case "file": + return { + text: `[文件: ${data.content?.fileName || "文件"}]`, + messageType: "file", + }; + default: + return { text: data.text?.content?.trim() || `[${msgtype}消息]`, messageType: msgtype }; + } +} + +export async function processDingTalkMessage(params: { + cfg: OpenClawConfig; + accountId: string; + data: DingTalkPayload; + log?: DingTalkLogger; + resolvedConfig?: ReturnType; +}) { + const cfg = params.cfg; + const accountId = params.accountId; + const dtCfg = params.resolvedConfig ?? resolveDingTalkConfig({ cfg, accountId }); + const data = params.data; + + const isGroup = data.conversationType === "2"; + const senderId = data.senderStaffId || data.senderId || ""; + const senderName = data.senderNick || "Unknown"; + const chatId = data.conversationId || ""; + const sessionWebhook = data.sessionWebhook; + + const content = extractDingTalkContent(data); + if (!content.text) { + return; + } + + if (senderId) { + await upsertDingTalkKnownUser({ userId: senderId, name: senderName }).catch(() => { + // best effort cache for target-name resolution in outbound tools + }); + } + + // group enabled + policy check + if (isGroup && !resolveDingTalkGroupEnabled({ cfg, accountId, chatId })) { + return; + } + + const storeAllowFrom = await readDingTalkAllowFromStore().catch(() => []); + + // DM policy / pairing + if (!isGroup) { + const dmPolicy = dtCfg.dmPolicy; + if (dmPolicy === "disabled") { + return; + } + if (dmPolicy !== "open") { + const dmAllow = normalizeAllowFromWithStore({ allowFrom: dtCfg.allowFrom, storeAllowFrom }); + const allowMatch = resolveSenderAllowMatch({ allow: dmAllow, senderId }); + const allowed = dmAllow.hasWildcard || (dmAllow.hasEntries && allowMatch.allowed); + if (!allowed) { + if (dmPolicy === "pairing") { + const { code, created } = await upsertDingTalkPairingRequest({ + userId: senderId, + name: senderName, + }); + if (created && sessionWebhook) { + const account = resolveDingTalkAccount({ cfg, accountId }); + await sendDingTalkWebhookText( + account.config, + sessionWebhook, + [ + "OpenClaw access not configured.", + "", + `Your DingTalk User ID: ${senderId}`, + "", + `Pairing code: ${code}`, + "", + "Ask the OpenClaw admin to approve with:", + `openclaw pairing approve dingtalk ${code}`, + ].join("\n"), + ); + } + } + return; + } + } + } + + // group policy + allowlist + if (isGroup) { + const groupPolicy = dtCfg.groupPolicy; + if (groupPolicy === "disabled") { + return; + } + if (groupPolicy === "allowlist") { + const groupAllow = normalizeAllowFromWithStore({ + allowFrom: dtCfg.groupAllowFrom.length > 0 ? dtCfg.groupAllowFrom : dtCfg.allowFrom, + storeAllowFrom, + }); + if (!groupAllow.hasEntries) { + return; + } + if (!isSenderAllowed({ allow: groupAllow, senderId })) { + return; + } + } + } + + // mention gating + if (isGroup) { + const { groupConfig } = resolveDingTalkGroupConfig({ cfg, accountId, chatId }); + const requireMention = groupConfig?.requireMention ?? true; + const wasMentioned = + data.isInAtList === true || + Boolean( + data.chatbotUserId && data.atUsers?.some((user) => user.userId === data.chatbotUserId), + ); + if (requireMention && !wasMentioned) { + return; + } + } + + const agentId = resolveSessionAgentId({ config: cfg }); + const account = resolveDingTalkAccount({ cfg, accountId }); + const peer: { kind: "group" | "dm"; id: string } = { + kind: isGroup ? "group" : "dm", + id: isGroup ? chatId : senderId, + }; + const sessionKey = buildAgentSessionKey({ + agentId, + channel: "dingtalk", + accountId, + peer, + dmScope: cfg.session?.dmScope ?? "main", + identityLinks: cfg.session?.identityLinks, + }); + + const ctx = { + Body: content.text, + RawBody: content.text, + From: senderId, + To: isGroup ? chatId : senderId, + SenderId: senderId, + SenderName: senderName, + ChatType: isGroup ? "group" : "dm", + Provider: "dingtalk", + Surface: "dingtalk", + Timestamp: Date.now(), + MessageSid: data.msgId || data.messageId, + AccountId: accountId, + OriginatingChannel: "dingtalk", + OriginatingTo: isGroup ? `group:${chatId}` : `user:${senderId}`, + GroupSystemPrompt: dtCfg.enableMediaUpload ? buildDingTalkMediaSystemPrompt() : undefined, + }; + + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + await recordInboundSession({ + storePath, + sessionKey, + ctx, + updateLastRoute: { + sessionKey, + channel: "dingtalk", + to: isGroup ? `group:${chatId}` : `user:${senderId}`, + accountId, + }, + onRecordError: (err) => logVerbose(`dingtalk: failed updating session meta: ${String(err)}`), + }); + + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId, + channel: "dingtalk", + accountId, + }); + const streamingSession = dtCfg.streaming + ? new DingTalkStreamingSession(account.config, { isGroup, senderId, chatId }) + : null; + let latestStreamText = ""; + let streamClosed = false; + let streamClosePromise: Promise | null = null; + + const closeStreamingSessionIfNeeded = async () => { + if (!streamingSession?.isActive() || streamClosed) { + return; + } + if (streamClosePromise) { + await streamClosePromise; + return; + } + streamClosePromise = (async () => { + await streamingSession.close(latestStreamText, params.log); + streamClosed = true; + })().finally(() => { + streamClosePromise = null; + }); + await streamClosePromise; + }; + + try { + await dispatchReplyWithBufferedBlockDispatcher({ + ctx, + cfg, + dispatcherOptions: { + ...prefixOptions, + deliver: async (payload, info) => { + if (!payload.text && !payload.mediaUrl && !payload.mediaUrls?.length) { + return; + } + + // streaming 更新 + if (streamingSession?.isActive() && info?.kind === "block" && payload.text) { + latestStreamText = payload.text; + await streamingSession.update(payload.text, params.log); + return; + } + + if (streamingSession?.isActive() && info?.kind === "final") { + const oapiToken = dtCfg.enableMediaUpload + ? await getDingTalkOapiToken(account.config) + : null; + let finalText = payload.text ?? ""; + finalText = await processLocalImages(finalText, oapiToken, params.log); + finalText = await processVideoMarkers( + finalText, + "", + account.config, + oapiToken, + params.log, + true, + streamingSession.target, + ); + finalText = await processAudioMarkers( + finalText, + "", + account.config, + oapiToken, + params.log, + true, + streamingSession.target, + ); + finalText = await processFileMarkers( + finalText, + "", + account.config, + oapiToken, + params.log, + true, + streamingSession.target, + ); + latestStreamText = finalText; + await closeStreamingSessionIfNeeded(); + return; + } + + // 非流式:直接 webhook 回复 + if (payload.text && sessionWebhook) { + let finalText = payload.text; + if (dtCfg.enableMediaUpload) { + const oapiToken = await getDingTalkOapiToken(account.config); + finalText = await processLocalImages(finalText, oapiToken, params.log); + finalText = await processVideoMarkers( + finalText, + sessionWebhook, + account.config, + oapiToken, + params.log, + ); + finalText = await processAudioMarkers( + finalText, + sessionWebhook, + account.config, + oapiToken, + params.log, + ); + finalText = await processFileMarkers( + finalText, + sessionWebhook, + account.config, + oapiToken, + params.log, + ); + } + await sendDingTalkWebhookText(account.config, sessionWebhook, finalText, { + useMarkdown: true, + atUserId: isGroup ? senderId : null, + }); + } + }, + onError: (err) => logger.error(`Reply error: ${String(err)}`), + onReplyStart: async () => { + if (streamingSession && !streamingSession.isActive()) { + await streamingSession.start(params.log); + } + }, + onIdle: () => { + void closeStreamingSessionIfNeeded().catch((err) => { + logger.error(`DingTalk streaming close on idle failed: ${String(err)}`); + }); + }, + }, + replyOptions: { + disableBlockStreaming: !dtCfg.blockStreaming, + onModelSelected, + }, + }); + } finally { + await closeStreamingSessionIfNeeded().catch((err) => { + logger.error(`DingTalk streaming close after dispatch failed: ${String(err)}`); + }); + } +} diff --git a/src/dingtalk/monitor.ts b/src/dingtalk/monitor.ts new file mode 100644 index 000000000000..54a1033c1872 --- /dev/null +++ b/src/dingtalk/monitor.ts @@ -0,0 +1,129 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { loadConfig } from "../config/config.js"; +import { getChildLogger } from "../logging.js"; +import { resolveDingTalkAccount } from "./accounts.js"; +import { resolveDingTalkConfig } from "./config.js"; +import { loadDingTalkStreamModule } from "./deps.js"; +import { processDingTalkMessage } from "./message.js"; + +const { DWClient, TOPIC_ROBOT } = loadDingTalkStreamModule(); + +const logger = getChildLogger({ module: "dingtalk-monitor" }); +const processedMessages = new Map(); +const MESSAGE_DEDUP_TTL = 5 * 60 * 1000; + +function cleanupProcessedMessages() { + const now = Date.now(); + for (const [msgId, ts] of processedMessages.entries()) { + if (now - ts > MESSAGE_DEDUP_TTL) { + processedMessages.delete(msgId); + } + } +} + +function markMessageProcessed(messageId?: string) { + if (!messageId) { + return; + } + processedMessages.set(messageId, Date.now()); + if (processedMessages.size >= 100) { + cleanupProcessedMessages(); + } +} + +function isMessageProcessed(messageId?: string) { + if (!messageId) { + return false; + } + return processedMessages.has(messageId); +} + +export async function monitorDingTalkProvider(opts: { + config?: OpenClawConfig; + accountId?: string; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + onConnected?: () => void; + onDisconnected?: () => void; + onInbound?: () => void; +}) { + const cfg = opts.config ?? loadConfig(); + const account = resolveDingTalkAccount({ cfg, accountId: opts.accountId }); + const dtCfg = resolveDingTalkConfig({ cfg, accountId: account.accountId }); + + if (!account.config.clientId || !account.config.clientSecret) { + throw new Error(`DingTalk credentials missing for account ${account.accountId}`); + } + if (!dtCfg.enabled || !account.enabled) { + logger.info(`DingTalk account ${account.accountId} disabled, skipping`); + return; + } + + const client = new DWClient({ + clientId: account.config.clientId, + clientSecret: account.config.clientSecret, + debug: account.config.debug || false, + }); + + client.registerCallbackListener(TOPIC_ROBOT, async (res: unknown) => { + const msg = res as { headers?: { messageId?: string }; data?: string }; + const messageId = msg.headers?.messageId; + if (messageId) { + client.socketCallBackResponse(messageId, { success: true }); + } + if (messageId && isMessageProcessed(messageId)) { + return; + } + markMessageProcessed(messageId); + opts.onInbound?.(); + + try { + const data = JSON.parse(msg.data as string); + await processDingTalkMessage({ + cfg, + accountId: account.accountId, + data, + log: logger, + resolvedConfig: dtCfg, + }); + } catch (err) { + logger.error(`DingTalk message processing error: ${String(err)}`); + } + }); + + await client.connect(); + logger.info(`DingTalk stream connected (${account.accountId})`); + opts.onConnected?.(); + + const stop = () => { + try { + client.disconnect(); + } catch { + // best-effort shutdown + } + opts.onDisconnected?.(); + }; + + const abortSignal = opts.abortSignal; + if (!abortSignal) { + await new Promise(() => {}); + return; + } + + if (abortSignal.aborted) { + logger.info(`DingTalk stream stopping (${account.accountId})`); + stop(); + return; + } + + await new Promise((resolve) => { + const onAbort = () => { + logger.info(`DingTalk stream stopping (${account.accountId})`); + abortSignal.removeEventListener("abort", onAbort); + stop(); + resolve(); + }; + abortSignal.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/src/dingtalk/pairing-store.ts b/src/dingtalk/pairing-store.ts new file mode 100644 index 000000000000..0485d97d8ffa --- /dev/null +++ b/src/dingtalk/pairing-store.ts @@ -0,0 +1,118 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { + addChannelAllowFromStoreEntry, + approveChannelPairingCode, + listChannelPairingRequests, + readChannelAllowFromStore, + upsertChannelPairingRequest, +} from "../pairing/pairing-store.js"; + +const PROVIDER = "dingtalk" as const; + +export type DingTalkPairingListEntry = { + userId: string; + code: string; + createdAt: string; + lastSeenAt: string; + name?: string; +}; + +export async function readDingTalkAllowFromStore( + env: NodeJS.ProcessEnv = process.env, +): Promise { + return readChannelAllowFromStore(PROVIDER, env); +} + +export async function addDingTalkAllowFromStoreEntry(params: { + entry: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ changed: boolean; allowFrom: string[] }> { + return addChannelAllowFromStoreEntry({ + channel: PROVIDER, + entry: params.entry, + env: params.env, + }); +} + +export async function listDingTalkPairingRequests( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const list = await listChannelPairingRequests(PROVIDER, env); + return list.map((r) => ({ + userId: r.id, + code: r.code, + createdAt: r.createdAt, + lastSeenAt: r.lastSeenAt, + name: r.meta?.name, + })); +} + +export async function upsertDingTalkPairingRequest(params: { + userId: string; + name?: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ code: string; created: boolean }> { + return upsertChannelPairingRequest({ + channel: PROVIDER, + id: params.userId, + env: params.env, + meta: { name: params.name }, + }); +} + +export async function approveDingTalkPairingCode(params: { + code: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ userId: string; entry?: DingTalkPairingListEntry } | null> { + const res = await approveChannelPairingCode({ + channel: PROVIDER, + code: params.code, + env: params.env, + }); + if (!res) { + return null; + } + const entry = res.entry + ? { + userId: res.entry.id, + code: res.entry.code, + createdAt: res.entry.createdAt, + lastSeenAt: res.entry.lastSeenAt, + name: res.entry.meta?.name, + } + : undefined; + return { userId: res.id, entry }; +} + +export async function resolveDingTalkEffectiveAllowFrom(params: { + cfg: OpenClawConfig; + accountId?: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ dm: string[]; group: string[] }> { + const env = params.env ?? process.env; + const dtCfg = params.cfg.channels?.dingtalk; + const accountCfg = params.accountId ? dtCfg?.accounts?.[params.accountId] : undefined; + const allowFrom = accountCfg?.allowFrom ?? dtCfg?.allowFrom ?? []; + const groupAllowFrom = accountCfg?.groupAllowFrom ?? dtCfg?.groupAllowFrom ?? []; + + const cfgAllowFrom = allowFrom + .map((v) => String(v).trim()) + .filter(Boolean) + .map((v) => v.replace(/^dingtalk:/i, "")) + .filter((v) => v !== "*"); + const cfgGroupAllowFrom = groupAllowFrom + .map((v) => String(v).trim()) + .filter(Boolean) + .map((v) => v.replace(/^dingtalk:/i, "")) + .filter((v) => v !== "*"); + const storeAllowFrom = await readDingTalkAllowFromStore(env); + + const dm = Array.from(new Set([...cfgAllowFrom, ...storeAllowFrom])); + const group = Array.from( + new Set([ + ...(cfgGroupAllowFrom.length > 0 ? cfgGroupAllowFrom : cfgAllowFrom), + ...storeAllowFrom, + ]), + ); + return { dm, group }; +} diff --git a/src/dingtalk/probe.ts b/src/dingtalk/probe.ts new file mode 100644 index 000000000000..5cb32336e24f --- /dev/null +++ b/src/dingtalk/probe.ts @@ -0,0 +1,24 @@ +import { getDingTalkAccessToken } from "./auth.js"; + +export async function probeDingTalk( + clientId: string, + clientSecret: string, + timeoutMs = 5000, +): Promise<{ ok: boolean; tokenPresent: boolean; error?: string }> { + try { + const token = await Promise.race([ + getDingTalkAccessToken({ clientId, clientSecret }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("DingTalk probe timeout")), timeoutMs), + ), + ]); + return { ok: Boolean(token), tokenPresent: Boolean(token) }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + tokenPresent: false, + error: message, + }; + } +} diff --git a/src/dingtalk/send.ts b/src/dingtalk/send.ts new file mode 100644 index 000000000000..b3902fff2ab8 --- /dev/null +++ b/src/dingtalk/send.ts @@ -0,0 +1,636 @@ +import type { AICardTarget } from "./ai-card.js"; +import { createAICardForTarget, finishAICard } from "./ai-card.js"; +import { getDingTalkAccessToken, getDingTalkOapiToken } from "./auth.js"; +import { loadDingTalkAxios } from "./deps.js"; +import { + processAudioMarkers, + processFileMarkers, + processLocalImages, + processVideoMarkers, +} from "./media.js"; + +const axios = loadDingTalkAxios(); + +export type DingTalkMsgType = "text" | "markdown" | "link" | "actionCard" | "image"; + +export type DingTalkSendResult = { + ok: boolean; + processQueryKey?: string; + cardInstanceId?: string; + error?: string; + usedAICard?: boolean; +}; + +type Logger = { + info?: (message: string) => void; + warn?: (message: string) => void; + error?: (message: string) => void; +}; + +type ErrorResponse = { + status?: number; + data?: unknown; +}; + +function getErrorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + return String(err); +} + +function getErrorResponse(err: unknown): ErrorResponse | undefined { + if (typeof err !== "object" || err === null || !("response" in err)) { + return undefined; + } + const response = (err as { response?: unknown }).response; + if (typeof response !== "object" || response === null) { + return undefined; + } + const statusValue = (response as { status?: unknown }).status; + const data = (response as { data?: unknown }).data; + return { + status: typeof statusValue === "number" ? statusValue : undefined, + data, + }; +} + +function toRecord(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + return value as Record; +} + +function getStringField(data: unknown, field: string): string | undefined { + const value = toRecord(data)?.[field]; + return typeof value === "string" ? value : undefined; +} + +function getResponseMessage(data: unknown): string | undefined { + return getStringField(data, "message"); +} + +function getProcessQueryKey(data: unknown): string | undefined { + return getStringField(data, "processQueryKey"); +} + +type ProactiveSendOptions = { + msgType?: DingTalkMsgType; + title?: string; + log?: Logger; + useAICard?: boolean; + fallbackToNormal?: boolean; +}; + +export async function sendDingTalkWebhookText( + config: { clientId: string; clientSecret: string }, + sessionWebhook: string, + text: string, + opts: { atUserId?: string | null; title?: string; useMarkdown?: boolean } = {}, +): Promise { + const token = await getDingTalkAccessToken(config); + let payloadText = text; + const hasMarkdown = /^[#*>-]|[*_`#[\]]/.test(text) || text.includes("\n"); + const useMarkdown = opts.useMarkdown !== false && (opts.useMarkdown || hasMarkdown); + + if (useMarkdown) { + const title = + opts.title || + text + .split("\n")[0] + ?.replace(/^[#*\s\->]+/, "") + .slice(0, 20) || + "OpenClaw"; + if (opts.atUserId) { + payloadText = `${payloadText} @${opts.atUserId}`; + } + const body: Record = { + msgtype: "markdown", + markdown: { title, text: payloadText }, + }; + if (opts.atUserId) { + body.at = { atUserIds: [opts.atUserId], isAtAll: false }; + } + const resp = await axios.post(sessionWebhook, body, { + headers: { + "x-acs-dingtalk-access-token": token, + "Content-Type": "application/json", + }, + }); + return resp.data; + } + + const body: Record = { msgtype: "text", text: { content: payloadText } }; + if (opts.atUserId) { + body.at = { atUserIds: [opts.atUserId], isAtAll: false }; + } + const resp = await axios.post(sessionWebhook, body, { + headers: { + "x-acs-dingtalk-access-token": token, + "Content-Type": "application/json", + }, + }); + return resp.data; +} + +async function sendAICardInternal( + config: { clientId: string; clientSecret: string; aiCardTemplateId?: string }, + target: AICardTarget, + content: string, + log?: Logger, +): Promise { + const targetDesc = + target.type === "group" ? `群聊 ${target.openConversationId}` : `用户 ${target.userId}`; + + try { + // 0. 获取 oapiToken 用于后处理 + const oapiToken = await getDingTalkOapiToken(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. 检查处理后的内容是否为空(纯文件/视频/音频消息场景) + const trimmedContent = processedContent.trim(); + if (!trimmedContent) { + log?.info?.(`[DingTalk][AICard][Proactive] 处理后内容为空,跳过创建 AI Card`); + return { ok: true, usedAICard: false }; + } + + // 6. 创建卡片 + const card = await createAICardForTarget(config, target, log); + if (!card) { + return { ok: false, error: "Failed to create AI Card", usedAICard: false }; + } + + // 7. 使用 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: unknown) { + const errMessage = getErrorMessage(err); + const response = getErrorResponse(err); + log?.error?.(`[DingTalk][AICard][Proactive] AI Card 发送失败 (${targetDesc}): ${errMessage}`); + if (response) { + log?.error?.( + `[DingTalk][AICard][Proactive] 错误响应: status=${response.status} data=${JSON.stringify(response.data)}`, + ); + } + return { + ok: false, + error: getResponseMessage(response?.data) || errMessage, + usedAICard: false, + }; + } +} + +async function sendAICardToUser( + config: { clientId: string; clientSecret: string; aiCardTemplateId?: string }, + userId: string, + content: string, + log?: Logger, +): Promise { + return sendAICardInternal(config, { type: "user", userId }, content, log); +} + +async function sendAICardToGroup( + config: { clientId: string; clientSecret: string; aiCardTemplateId?: string }, + openConversationId: string, + content: string, + log?: Logger, +): Promise { + return sendAICardInternal(config, { type: "group", openConversationId }, content, log); +} + +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 }, + }; + } +} + +async function sendNormalToUser( + config: { clientId: string; clientSecret: string }, + userIds: string | string[], + content: string, + options: { msgType?: DingTalkMsgType; title?: string; log?: Logger } = {}, +): 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 getDingTalkAccessToken(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( + "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend", + body, + { + headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" }, + timeout: 10_000, + }, + ); + + const processQueryKey = getProcessQueryKey(resp.data); + if (processQueryKey) { + log?.info?.(`[DingTalk][Normal] 发送成功: processQueryKey=${processQueryKey}`); + return { ok: true, processQueryKey, usedAICard: false }; + } + + log?.warn?.(`[DingTalk][Normal] 发送响应异常: ${JSON.stringify(resp.data)}`); + return { + ok: false, + error: getResponseMessage(resp.data) || "Unknown error", + usedAICard: false, + }; + } catch (err: unknown) { + const errMsg = getResponseMessage(getErrorResponse(err)?.data) || getErrorMessage(err); + log?.error?.(`[DingTalk][Normal] 发送失败: ${errMsg}`); + return { ok: false, error: errMsg, usedAICard: false }; + } +} + +async function sendNormalToGroup( + config: { clientId: string; clientSecret: string }, + openConversationId: string, + content: string, + options: { msgType?: DingTalkMsgType; title?: string; log?: Logger } = {}, +): 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 getDingTalkAccessToken(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("https://api.dingtalk.com/v1.0/robot/groupMessages/send", body, { + headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" }, + timeout: 10_000, + }); + + const processQueryKey = getProcessQueryKey(resp.data); + if (processQueryKey) { + log?.info?.(`[DingTalk][Normal] 发送成功: processQueryKey=${processQueryKey}`); + return { ok: true, processQueryKey, usedAICard: false }; + } + + log?.warn?.(`[DingTalk][Normal] 发送响应异常: ${JSON.stringify(resp.data)}`); + return { + ok: false, + error: getResponseMessage(resp.data) || "Unknown error", + usedAICard: false, + }; + } catch (err: unknown) { + const errMsg = getResponseMessage(getErrorResponse(err)?.data) || getErrorMessage(err); + log?.error?.(`[DingTalk][Normal] 发送失败: ${errMsg}`); + return { ok: false, error: errMsg, usedAICard: false }; + } +} + +async function sendToUser( + config: { clientId: string; clientSecret: string; aiCardTemplateId?: string }, + 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; + } + + 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); +} + +async function sendToGroup( + config: { clientId: string; clientSecret: string; aiCardTemplateId?: string }, + 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 }; + } + + if (useAICard) { + log?.info?.( + `[DingTalk][SendToGroup] 尝试使用 AI Card 发送: openConversationId=${openConversationId}`, + ); + const cardResult = await sendAICardToGroup(config, openConversationId, content, log); + + if (cardResult.ok) { + return cardResult; + } + + 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); +} + +async function sendProactive( + config: { clientId: string; clientSecret: string; aiCardTemplateId?: string }, + target: { userId?: string; userIds?: string[]; openConversationId?: string }, + content: string, + options: ProactiveSendOptions = {}, +): Promise { + 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, + }; +} + +export async function sendDingTalkProactiveText( + config: { clientId: string; clientSecret: string; aiCardTemplateId?: string }, + target: AICardTarget, + text: string, + opts?: { msgType?: DingTalkMsgType; title?: string; log?: Logger }, +): Promise { + const result = await sendProactive( + config, + target.type === "group" + ? { openConversationId: target.openConversationId } + : { userId: target.userId }, + text, + { msgType: opts?.msgType, title: opts?.title, log: opts?.log }, + ); + if (!result.ok) { + throw new Error(result.error || "DingTalk proactive send failed"); + } + return result; +} + +export async function sendDingTalkProactiveFile( + config: { clientId: string; clientSecret: string }, + target: AICardTarget, + payload: { mediaId: string; fileName: string; fileType: string }, + log?: Logger, +): Promise { + const token = await getDingTalkAccessToken(config); + const msgParam = { + mediaId: payload.mediaId, + fileName: payload.fileName, + fileType: payload.fileType, + }; + const body: Record = { + robotCode: config.clientId, + msgKey: "sampleFile", + msgParam: JSON.stringify(msgParam), + }; + let endpoint: string; + if (target.type === "group") { + body.openConversationId = target.openConversationId; + endpoint = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"; + } else { + body.userIds = [target.userId]; + endpoint = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"; + } + log?.info?.(`[DingTalk][File][Proactive] 发送文件消息: ${payload.fileName}`); + await axios.post(endpoint, body, { + headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" }, + timeout: 10_000, + }); +} + +export async function sendDingTalkProactiveAudio( + config: { clientId: string; clientSecret: string }, + target: AICardTarget, + payload: { mediaId: string; durationMs?: number }, + log?: Logger, +): Promise { + const token = await getDingTalkAccessToken(config); + const msgParam = { + mediaId: payload.mediaId, + duration: String(payload.durationMs ?? 60000), + }; + const body: Record = { + robotCode: config.clientId, + msgKey: "sampleAudio", + msgParam: JSON.stringify(msgParam), + }; + let endpoint: string; + if (target.type === "group") { + body.openConversationId = target.openConversationId; + endpoint = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"; + } else { + body.userIds = [target.userId]; + endpoint = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"; + } + log?.info?.(`[DingTalk][Audio][Proactive] 发送音频消息`); + await axios.post(endpoint, body, { + headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" }, + timeout: 10_000, + }); +} + +export async function sendDingTalkProactiveVideo( + config: { clientId: string; clientSecret: string }, + target: AICardTarget, + payload: { + videoMediaId: string; + picMediaId: string; + duration: number; + width: number; + height: number; + }, + log?: Logger, +): Promise { + const token = await getDingTalkAccessToken(config); + const msgParam = { + duration: String(payload.duration), + videoMediaId: payload.videoMediaId, + videoType: "mp4", + picMediaId: payload.picMediaId, + }; + const body: Record = { + robotCode: config.clientId, + msgKey: "sampleVideo", + msgParam: JSON.stringify(msgParam), + }; + let endpoint: string; + if (target.type === "group") { + body.openConversationId = target.openConversationId; + endpoint = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"; + } else { + body.userIds = [target.userId]; + endpoint = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"; + } + log?.info?.(`[DingTalk][Video][Proactive] 发送视频消息`); + await axios.post(endpoint, body, { + headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" }, + timeout: 10_000, + }); +} diff --git a/src/dingtalk/targets.ts b/src/dingtalk/targets.ts new file mode 100644 index 000000000000..cfc96971223e --- /dev/null +++ b/src/dingtalk/targets.ts @@ -0,0 +1,33 @@ +export type DingTalkTarget = { type: "user" | "group"; id: string }; + +export function normalizeDingTalkTarget(raw: string): string { + let normalized = raw.replace(/^(dingtalk|dingtalk-connector|dd|ding):/i, "").trim(); + normalized = normalized.replace(/^(user|group):/i, "").trim(); + return normalized; +} + +export function resolveDingTalkTargetType(target: string): "user" | "group" { + const trimmed = target.trim(); + if (trimmed.startsWith("user:")) { + return "user"; + } + if (trimmed.startsWith("group:")) { + return "group"; + } + if (trimmed.includes("=") || trimmed.length > 30) { + return "group"; + } + return "user"; +} + +export function parseDingTalkTarget(target: string): DingTalkTarget { + const trimmed = target.trim(); + if (trimmed.startsWith("user:")) { + return { type: "user", id: trimmed.slice("user:".length) }; + } + if (trimmed.startsWith("group:")) { + return { type: "group", id: trimmed.slice("group:".length) }; + } + const normalized = normalizeDingTalkTarget(trimmed); + return { type: resolveDingTalkTargetType(trimmed), id: normalized }; +} diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index c1538b84246c..d701663a2fbf 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -816,6 +816,45 @@ function resolveTlonSession( }; } +function resolveDingTalkSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + let trimmed = stripProviderPrefix(params.target, "dingtalk").trim(); + trimmed = stripProviderPrefix(trimmed, "dingtalk-connector").trim(); + if (!trimmed) { + return null; + } + + const lower = trimmed.toLowerCase(); + let isGroup = false; + if (lower.startsWith("group:")) { + trimmed = trimmed.replace(/^group:/i, "").trim(); + isGroup = true; + } else if (lower.startsWith("user:") || lower.startsWith("dm:")) { + trimmed = trimmed.replace(/^(user|dm):/i, "").trim(); + isGroup = false; + } else if (trimmed.includes("=") || trimmed.length > 30) { + isGroup = true; + } + + const peer: RoutePeer = { kind: isGroup ? "group" : "dm", id: trimmed }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "dingtalk", + accountId: params.accountId, + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: isGroup ? "group" : "direct", + from: isGroup ? `dingtalk:group:${trimmed}` : `dingtalk:${trimmed}`, + to: isGroup ? `group:${trimmed}` : `user:${trimmed}`, + }; +} + /** * Feishu ID formats: * - oc_xxx: chat_id (group chat) @@ -945,6 +984,8 @@ export async function resolveOutboundSessionRoute( return resolveNostrSession({ ...params, target }); case "tlon": return resolveTlonSession({ ...params, target }); + case "dingtalk": + return resolveDingTalkSession({ ...params, target }); case "feishu": return resolveFeishuSession({ ...params, target }); default: diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index cbbbf65aa7ea..ace51fabcb16 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -94,6 +94,27 @@ export type { MSTeamsReplyStyle, MSTeamsTeamConfig, } from "../config/types.js"; +// DingTalk exports (added in later tasks) +export type { + DingTalkConfig, + DingTalkAccountConfig, + DingTalkGroupConfig, +} from "../config/types.dingtalk.js"; +export { + listDingTalkAccountIds, + resolveDefaultDingTalkAccountId, + resolveDingTalkAccount, + type ResolvedDingTalkAccount, + resolveDingTalkConfig, + resolveDingTalkGroupRequireMention, + resolveDingTalkGroupEnabled, + readDingTalkAllowFromStore, + readDingTalkKnownUsers, + normalizeDingTalkTarget, + dingtalkOutbound, + monitorDingTalkProvider, + probeDingTalk, +} from "../dingtalk/index.js"; export { DiscordConfigSchema, GoogleChatConfigSchema,