From 92c54ff309ce4215ab6752670acb4cd40e536a77 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 08:03:43 +0000 Subject: [PATCH] feat(channels): add DingTalk channel adapter - Add @qwen-code/channel-dingtalk package with stream-based bot integration - Support clientId/clientSecret authentication for DingTalk - Add message deduplication and group chat mention handling - Update ChannelConfig type to include dingtalk channel type Co-authored-by: Qwen-Coder --- package-lock.json | 73 +++++++- package.json | 3 +- packages/channels/base/src/types.ts | 9 +- packages/channels/dingtalk/package.json | 18 ++ .../channels/dingtalk/src/DingtalkAdapter.ts | 168 ++++++++++++++++++ packages/channels/dingtalk/src/index.ts | 1 + packages/cli/package.json | 1 + .../cli/src/commands/channel/config-utils.ts | 19 +- packages/cli/src/commands/channel/start.ts | 4 + 9 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 packages/channels/dingtalk/package.json create mode 100644 packages/channels/dingtalk/src/DingtalkAdapter.ts create mode 100644 packages/channels/dingtalk/src/index.ts diff --git a/package-lock.json b/package-lock.json index b5ef4afbb..caf9f2156 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "packages/*", "packages/channels/base", "packages/channels/telegram", - "packages/channels/weixin" + "packages/channels/weixin", + "packages/channels/dingtalk" ], "dependencies": { "@testing-library/dom": "^10.4.1", @@ -2997,6 +2998,10 @@ "resolved": "packages/channels/base", "link": true }, + "node_modules/@qwen-code/channel-dingtalk": { + "resolved": "packages/channels/dingtalk", + "link": true + }, "node_modules/@qwen-code/channel-telegram": { "resolved": "packages/channels/telegram", "link": true @@ -6442,6 +6447,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/azure-devops-node-api": { "version": "12.5.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", @@ -8102,6 +8118,17 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dingtalk-stream-sdk-nodejs": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/dingtalk-stream-sdk-nodejs/-/dingtalk-stream-sdk-nodejs-2.0.4.tgz", + "integrity": "sha512-aVHQ72zAZ6upfuwQXhLvorDZY47uyOp8cvMFVrvLOws8tVCiM1YwFcKvcPthOt9c2gaGdv3BXHtnLeLeWFAv8Q==", + "license": "MIT", + "dependencies": { + "axios": "^1.4.0", + "debug": "^4.3.4", + "ws": "^8.13.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -9777,6 +9804,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -9810,9 +9857,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -14707,6 +14754,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -18921,6 +18974,17 @@ "typescript": "^5.0.0" } }, + "packages/channels/dingtalk": { + "name": "@qwen-code/channel-dingtalk", + "version": "0.1.0", + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "dingtalk-stream-sdk-nodejs": "^2.0.4" + }, + "devDependencies": { + "typescript": "^5.0.0" + } + }, "packages/channels/telegram": { "name": "@qwen-code/channel-telegram", "version": "0.1.0", @@ -18952,6 +19016,7 @@ "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/channel-base": "file:../channels/base", + "@qwen-code/channel-dingtalk": "file:../channels/dingtalk", "@qwen-code/channel-telegram": "file:../channels/telegram", "@qwen-code/channel-weixin": "file:../channels/weixin", "@qwen-code/qwen-code-core": "file:../core", diff --git a/package.json b/package.json index 55d0ac11e..cf339100a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "packages/*", "packages/channels/base", "packages/channels/telegram", - "packages/channels/weixin" + "packages/channels/weixin", + "packages/channels/dingtalk" ], "repository": { "type": "git", diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 2f02cd510..0bea8aa15 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -1,6 +1,11 @@ export type SenderPolicy = 'allowlist' | 'pairing' | 'open'; export type SessionScope = 'user' | 'thread' | 'single'; -export type ChannelType = 'telegram' | 'weixin' | 'discord' | 'webhook'; +export type ChannelType = + | 'telegram' + | 'weixin' + | 'dingtalk' + | 'discord' + | 'webhook'; export type GroupPolicy = 'disabled' | 'allowlist' | 'open'; export interface GroupConfig { @@ -10,6 +15,8 @@ export interface GroupConfig { export interface ChannelConfig { type: ChannelType; token: string; + clientId?: string; + clientSecret?: string; senderPolicy: SenderPolicy; allowedUsers: string[]; sessionScope: SessionScope; diff --git a/packages/channels/dingtalk/package.json b/packages/channels/dingtalk/package.json new file mode 100644 index 000000000..facfd5894 --- /dev/null +++ b/packages/channels/dingtalk/package.json @@ -0,0 +1,18 @@ +{ + "name": "@qwen-code/channel-dingtalk", + "version": "0.1.0", + "description": "DingTalk channel adapter for Qwen Code", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "dingtalk-stream-sdk-nodejs": "^2.0.4" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts new file mode 100644 index 000000000..1959fb35d --- /dev/null +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -0,0 +1,168 @@ +import { DWClient, TOPIC_ROBOT, EventAck } from 'dingtalk-stream-sdk-nodejs'; +import type { + DWClientDownStream, + RobotMessage, +} from 'dingtalk-stream-sdk-nodejs'; +import { ChannelBase } from '@qwen-code/channel-base'; +import type { + ChannelConfig, + ChannelBaseOptions, + Envelope, + AcpBridge, +} from '@qwen-code/channel-base'; + +/** Track seen msgIds to deduplicate retried callbacks. */ +const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes + +export class DingtalkChannel extends ChannelBase { + private client: DWClient; + private seenMessages: Map = new Map(); + private dedupTimer?: ReturnType; + + constructor( + name: string, + config: ChannelConfig, + bridge: AcpBridge, + options?: ChannelBaseOptions, + ) { + super(name, config, bridge, options); + + if (!config.clientId || !config.clientSecret) { + throw new Error( + `Channel "${name}" requires clientId and clientSecret for DingTalk.`, + ); + } + + this.client = new DWClient({ + clientId: config.clientId, + clientSecret: config.clientSecret, + }); + } + + async connect(): Promise { + this.client.registerCallbackListener( + TOPIC_ROBOT, + (msg: DWClientDownStream) => { + // ACK immediately so DingTalk doesn't retry + this.client.send(msg.headers.messageId, { + status: EventAck.SUCCESS, + message: 'ok', + }); + this.onMessage(msg); + }, + ); + + await this.client.connect(); + + // Periodically clean up dedup map + this.dedupTimer = setInterval(() => { + const now = Date.now(); + for (const [id, ts] of this.seenMessages) { + if (now - ts > DEDUP_TTL_MS) { + this.seenMessages.delete(id); + } + } + }, 60_000); + + process.stderr.write(`[DingTalk:${this.name}] Connected via stream.\n`); + } + + async sendMessage(chatId: string, text: string): Promise { + // chatId is the sessionWebhook URL for DingTalk + const body = { + msgtype: 'markdown', + markdown: { + title: 'Reply', + text, + }, + }; + + const resp = await fetch(chatId, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + const detail = await resp.text().catch(() => ''); + process.stderr.write( + `[DingTalk:${this.name}] sendMessage failed: HTTP ${resp.status} ${detail}\n`, + ); + } + } + + disconnect(): void { + if (this.dedupTimer) { + clearInterval(this.dedupTimer); + } + this.client.disconnect(); + process.stderr.write(`[DingTalk:${this.name}] Disconnected.\n`); + } + + private onMessage(downstream: DWClientDownStream): void { + try { + const data: RobotMessage = + typeof downstream.data === 'string' + ? JSON.parse(downstream.data) + : (downstream.data as unknown as RobotMessage); + const msgId = data.msgId || downstream.headers.messageId; + + // Dedup: DingTalk retries unACKed messages + if (msgId && this.seenMessages.has(msgId)) { + return; + } + if (msgId) { + this.seenMessages.set(msgId, Date.now()); + } + + const isGroup = data.conversationType === '2'; + const text = data.text?.content?.trim() || ''; + const sessionWebhook = data.sessionWebhook; + + if (!sessionWebhook) { + process.stderr.write( + `[DingTalk:${this.name}] No sessionWebhook in message, skipping.\n`, + ); + return; + } + + // In group chats, check isInAtList from the raw data + const rawData = JSON.parse(downstream.data); + const isMentioned = Boolean(rawData.isInAtList); + + // Strip @bot mention from text + let cleanText = text; + if (isMentioned && data.senderNick) { + // DingTalk prepends the @mention text; remove it + cleanText = text.replace(/@\S+/g, '').trim(); + } + + const envelope: Envelope = { + channelName: this.name, + senderId: data.senderId || data.senderStaffId, + senderName: data.senderNick || 'Unknown', + chatId: sessionWebhook, // Use webhook URL as chatId for sendMessage + text: cleanText || text, + isGroup, + isMentioned, + isReplyToBot: false, + }; + + // Don't await — stream callback should return quickly + this.handleInbound(envelope).catch((err) => { + process.stderr.write( + `[DingTalk:${this.name}] Error handling message: ${err}\n`, + ); + // Try to send error reply + this.sendMessage( + sessionWebhook, + 'Sorry, something went wrong processing your message.', + ).catch(() => {}); + }); + } catch (err) { + process.stderr.write( + `[DingTalk:${this.name}] Failed to parse message: ${err}\n`, + ); + } + } +} diff --git a/packages/channels/dingtalk/src/index.ts b/packages/channels/dingtalk/src/index.ts new file mode 100644 index 000000000..80f7b912f --- /dev/null +++ b/packages/channels/dingtalk/src/index.ts @@ -0,0 +1 @@ +export { DingtalkChannel } from './DingtalkAdapter.js'; diff --git a/packages/cli/package.json b/packages/cli/package.json index ffdc4092e..bee927ccb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -43,6 +43,7 @@ "@qwen-code/channel-base": "file:../channels/base", "@qwen-code/channel-telegram": "file:../channels/telegram", "@qwen-code/channel-weixin": "file:../channels/weixin", + "@qwen-code/channel-dingtalk": "file:../channels/dingtalk", "@qwen-code/qwen-code-core": "file:../core", "@qwen-code/web-templates": "file:../web-templates", "@types/update-notifier": "^6.0.8", diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index 41af02d7a..e6da41cb5 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -23,7 +23,7 @@ export function findCliEntryPath(): string { throw new Error('Cannot determine CLI entry path'); } -const SUPPORTED_TYPES = ['telegram', 'weixin']; +const SUPPORTED_TYPES = ['telegram', 'weixin', 'dingtalk']; export function parseChannelConfig( name: string, @@ -41,16 +41,31 @@ export function parseChannelConfig( } let token = ''; - if (channelType !== 'weixin') { + if (channelType !== 'weixin' && channelType !== 'dingtalk') { if (!rawConfig['token']) { throw new Error(`Channel "${name}" is missing required field "token".`); } token = resolveEnvVars(rawConfig['token'] as string); } + // DingTalk uses clientId + clientSecret instead of token + let clientId: string | undefined; + let clientSecret: string | undefined; + if (channelType === 'dingtalk') { + if (!rawConfig['clientId'] || !rawConfig['clientSecret']) { + throw new Error( + `Channel "${name}" requires "clientId" and "clientSecret" for DingTalk.`, + ); + } + clientId = resolveEnvVars(rawConfig['clientId'] as string); + clientSecret = resolveEnvVars(rawConfig['clientSecret'] as string); + } + return { type: channelType as ChannelConfig['type'], token, + clientId, + clientSecret, senderPolicy: (rawConfig['senderPolicy'] as ChannelConfig['senderPolicy']) || 'allowlist', diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index 9ed68903d..927eb56e8 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -7,6 +7,7 @@ import { AcpBridge, SessionRouter } from '@qwen-code/channel-base'; import type { ChannelBase, ToolCallEvent } from '@qwen-code/channel-base'; import { TelegramChannel } from '@qwen-code/channel-telegram'; import { WeixinChannel } from '@qwen-code/channel-weixin'; +import { DingtalkChannel } from '@qwen-code/channel-dingtalk'; import { findCliEntryPath, parseChannelConfig } from './config-utils.js'; import { readServiceInfo, @@ -38,6 +39,9 @@ function createChannel( if (config.type === 'weixin') { return new WeixinChannel(name, config, bridge, options); } + if (config.type === 'dingtalk') { + return new DingtalkChannel(name, config, bridge, options); + } return new TelegramChannel(name, config, bridge, options); }