@yaoyuanchao/dingtalk 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaoyuanchao/dingtalk",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "DingTalk channel plugin for ClawdBot/OpenClaw with Stream Mode support",
6
6
  "license": "MIT",
package/src/accounts.ts CHANGED
@@ -1,56 +1,141 @@
1
1
  import type { ResolvedDingTalkAccount, DingTalkChannelConfig } from "./types.js";
2
- import { validateDingTalkConfig, type DingTalkConfig } from "./config-schema.js";
2
+ import { validateDingTalkAccountConfig, type DingTalkAccountConfig } from "./config-schema.js";
3
3
 
4
4
  const DEFAULT_ACCOUNT_ID = "default";
5
5
  const ENV_CLIENT_ID = "DINGTALK_CLIENT_ID";
6
6
  const ENV_CLIENT_SECRET = "DINGTALK_CLIENT_SECRET";
7
7
  const ENV_ROBOT_CODE = "DINGTALK_ROBOT_CODE";
8
8
 
9
+ /**
10
+ * List all configured account IDs.
11
+ * When `accounts` map exists, returns those keys. Otherwise returns ['default'].
12
+ */
9
13
  export function listDingTalkAccountIds(cfg: any): string[] {
10
14
  const channel = cfg?.channels?.dingtalk as DingTalkChannelConfig | undefined;
11
- if (!channel) return [DEFAULT_ACCOUNT_ID];
12
- return [DEFAULT_ACCOUNT_ID];
15
+ if (!channel) return [];
16
+
17
+ // Multi-account mode
18
+ if (channel.accounts && typeof channel.accounts === 'object') {
19
+ const ids = Object.keys(channel.accounts).filter(id => {
20
+ const acct = channel.accounts![id];
21
+ return acct && acct.enabled !== false;
22
+ });
23
+ if (ids.length > 0) return ids;
24
+ }
25
+
26
+ // Single-account fallback
27
+ if (channel.clientId) return [DEFAULT_ACCOUNT_ID];
28
+ return [];
13
29
  }
14
30
 
31
+ /**
32
+ * Resolve the default account ID.
33
+ */
15
34
  export function resolveDefaultDingTalkAccountId(cfg: any): string {
35
+ const channel = cfg?.channels?.dingtalk as DingTalkChannelConfig | undefined;
36
+ if (!channel) return DEFAULT_ACCOUNT_ID;
37
+
38
+ // Explicit defaultAccount setting
39
+ if (channel.defaultAccount) return channel.defaultAccount;
40
+
41
+ // First account from accounts map
42
+ if (channel.accounts && typeof channel.accounts === 'object') {
43
+ const ids = Object.keys(channel.accounts);
44
+ if (ids.length > 0) return ids[0];
45
+ }
46
+
16
47
  return DEFAULT_ACCOUNT_ID;
17
48
  }
18
49
 
50
+ /**
51
+ * Deduplicate accounts by clientId: if multiple accounts share the same
52
+ * clientId, only the first one is kept. Returns filtered account IDs.
53
+ */
54
+ export function deduplicateByClientId(cfg: any, accountIds: string[]): string[] {
55
+ const seen = new Set<string>();
56
+ return accountIds.filter(id => {
57
+ const account = resolveDingTalkAccount({ cfg, accountId: id });
58
+ if (!account.clientId) return true;
59
+ if (seen.has(account.clientId)) {
60
+ console.warn(`[dingtalk] Duplicate clientId "${account.clientId}" in account "${id}", skipping`);
61
+ return false;
62
+ }
63
+ seen.add(account.clientId);
64
+ return true;
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Resolve a single account by ID.
70
+ * Merges top-level base config with per-account overrides.
71
+ * Env vars only apply to the default account.
72
+ */
19
73
  export function resolveDingTalkAccount(params: {
20
74
  cfg: any;
21
75
  accountId?: string | null;
22
76
  }): ResolvedDingTalkAccount {
23
77
  const accountId = params.accountId?.trim() || DEFAULT_ACCOUNT_ID;
24
- const rawConfig = params.cfg?.channels?.dingtalk ?? {};
25
-
26
- // Merge configuration with environment variables
27
- // Environment variables serve as fallback for missing config values
28
- const configWithEnv = {
29
- ...rawConfig,
30
- clientId: rawConfig.clientId || process.env[ENV_CLIENT_ID],
31
- clientSecret: rawConfig.clientSecret || process.env[ENV_CLIENT_SECRET],
32
- robotCode: rawConfig.robotCode || process.env[ENV_ROBOT_CODE],
78
+ const rawChannel = params.cfg?.channels?.dingtalk ?? {};
79
+
80
+ // Separate base config from multi-account fields
81
+ const { accounts, defaultAccount, ...baseConfig } = rawChannel;
82
+
83
+ // Get per-account override (if accounts map exists and this account is in it)
84
+ let accountOverride: Record<string, any> = {};
85
+ if (accounts && typeof accounts === 'object' && accountId !== DEFAULT_ACCOUNT_ID) {
86
+ accountOverride = accounts[accountId] ?? {};
87
+ } else if (accounts && typeof accounts === 'object' && accountId === DEFAULT_ACCOUNT_ID) {
88
+ // "default" may also be an explicit key in accounts map
89
+ accountOverride = accounts[DEFAULT_ACCOUNT_ID] ?? {};
90
+ }
91
+
92
+ // Merge: base + account override (deep merge for `dm` object)
93
+ // Credentials (clientId, clientSecret, robotCode) are per-account and should NOT
94
+ // inherit from base — otherwise bot A's robotCode leaks into bot B's config.
95
+ const { clientId: _baseClientId, clientSecret: _baseClientSecret, robotCode: _baseRobotCode, ...baseNonCreds } = baseConfig;
96
+ const merged = {
97
+ ...baseNonCreds,
98
+ ...accountOverride,
99
+ // Credentials: use account override first, then base, but robotCode only inherits
100
+ // if clientId also inherits (i.e. they belong to the same bot)
101
+ clientId: accountOverride.clientId || baseConfig.clientId,
102
+ clientSecret: accountOverride.clientSecret || baseConfig.clientSecret,
103
+ robotCode: accountOverride.robotCode || (accountOverride.clientId ? undefined : baseConfig.robotCode),
104
+ // Deep merge dm
105
+ dm: { ...(baseConfig.dm ?? {}), ...(accountOverride.dm ?? {}) },
106
+ // Deep merge groups
107
+ groups: { ...(baseConfig.groups ?? {}), ...(accountOverride.groups ?? {}) },
33
108
  };
34
109
 
35
- // Validate configuration with Zod
36
- let validatedConfig: DingTalkConfig;
110
+ // Env var fallback only for default account
111
+ if (accountId === DEFAULT_ACCOUNT_ID) {
112
+ merged.clientId = merged.clientId || process.env[ENV_CLIENT_ID];
113
+ merged.clientSecret = merged.clientSecret || process.env[ENV_CLIENT_SECRET];
114
+ merged.robotCode = merged.robotCode || process.env[ENV_ROBOT_CODE];
115
+ }
116
+
117
+ // Validate merged config
118
+ let validatedConfig: DingTalkAccountConfig;
37
119
  let credentialSource: "config" | "env" | "none" = "none";
38
120
 
39
121
  try {
40
- validatedConfig = validateDingTalkConfig(configWithEnv);
122
+ validatedConfig = validateDingTalkAccountConfig(merged);
41
123
 
42
124
  // Determine credential source
43
- if (rawConfig.clientId && rawConfig.clientSecret) {
125
+ const rawClientId = accountOverride.clientId || baseConfig.clientId;
126
+ const rawClientSecret = accountOverride.clientSecret || baseConfig.clientSecret;
127
+ if (rawClientId && rawClientSecret) {
44
128
  credentialSource = "config";
45
129
  } else if (validatedConfig.clientId && validatedConfig.clientSecret) {
46
130
  credentialSource = "env";
47
131
  }
48
132
  } catch (error) {
49
- // If validation fails, throw a helpful error message
50
133
  const errorMsg = error instanceof Error ? error.message : String(error);
51
134
  throw new Error(
52
135
  `DingTalk configuration validation failed for account "${accountId}":\n${errorMsg}\n\n` +
53
- `Please check your configuration at channels.dingtalk or set environment variables:\n` +
136
+ `Please check your configuration at channels.dingtalk` +
137
+ (accountId !== DEFAULT_ACCOUNT_ID ? `.accounts.${accountId}` : '') +
138
+ ` or set environment variables:\n` +
54
139
  ` - ${ENV_CLIENT_ID}\n` +
55
140
  ` - ${ENV_CLIENT_SECRET}\n` +
56
141
  ` - ${ENV_ROBOT_CODE} (optional)`
@@ -61,7 +146,7 @@ export function resolveDingTalkAccount(params: {
61
146
 
62
147
  return {
63
148
  accountId,
64
- name: "DingTalk Bot",
149
+ name: validatedConfig.name || (accountId === DEFAULT_ACCOUNT_ID ? "DingTalk Bot" : accountId),
65
150
  enabled: validatedConfig.enabled,
66
151
  configured,
67
152
  clientId: validatedConfig.clientId,
package/src/channel.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { getDingTalkRuntime } from './runtime.js';
2
- import { resolveDingTalkAccount } from './accounts.js';
2
+ import { resolveDingTalkAccount, listDingTalkAccountIds, resolveDefaultDingTalkAccountId } from './accounts.js';
3
3
  import { startDingTalkMonitor } from './monitor.js';
4
4
  import { sendDingTalkRestMessage, uploadMediaFile, sendFileMessage, textToMarkdownFile } from './api.js';
5
5
  import { probeDingTalk } from './probe.js';
@@ -132,27 +132,35 @@ export const dingtalkPlugin = {
132
132
  },
133
133
 
134
134
  listAccountIds(cfg) {
135
- const channel = cfg?.channels?.dingtalk ?? {};
136
- if (channel.clientId) return ['default'];
137
- return [];
135
+ return listDingTalkAccountIds(cfg);
138
136
  },
139
137
 
140
138
  resolveAccount(cfg, accountId) {
141
139
  return resolveDingTalkAccount({ cfg, accountId });
142
140
  },
143
141
 
144
- defaultAccountId() {
145
- return 'default';
142
+ defaultAccountId(cfg) {
143
+ return resolveDefaultDingTalkAccountId(cfg);
146
144
  },
147
145
 
148
146
  setAccountEnabled({ cfg, accountId, enabled }) {
149
147
  const runtime = getDingTalkRuntime();
150
- runtime.config.set('channels.dingtalk.enabled', enabled);
148
+ const channel = cfg?.channels?.dingtalk;
149
+ if (channel?.accounts && accountId && accountId !== 'default') {
150
+ runtime.config.set(`channels.dingtalk.accounts.${accountId}.enabled`, enabled);
151
+ } else {
152
+ runtime.config.set('channels.dingtalk.enabled', enabled);
153
+ }
151
154
  },
152
155
 
153
156
  deleteAccount({ cfg, accountId }) {
154
157
  const runtime = getDingTalkRuntime();
155
- runtime.config.delete('channels.dingtalk');
158
+ const channel = cfg?.channels?.dingtalk;
159
+ if (channel?.accounts && accountId && accountId !== 'default') {
160
+ runtime.config.delete(`channels.dingtalk.accounts.${accountId}`);
161
+ } else {
162
+ runtime.config.delete('channels.dingtalk');
163
+ }
156
164
  },
157
165
 
158
166
  isConfigured(account) {
@@ -1,151 +1,167 @@
1
- import { z } from 'zod';
2
-
3
- // 定义枚举类型
4
- export const dmPolicySchema = z.enum(['disabled', 'pairing', 'allowlist', 'open'], {
5
- description: 'DM access control policy',
6
- });
7
-
8
- export const groupPolicySchema = z.enum(['disabled', 'allowlist', 'open'], {
9
- description: 'Group chat access control policy',
10
- });
11
-
12
- export const messageFormatSchema = z.enum(['text', 'markdown', 'richtext', 'auto'], {
13
- description: 'Message format for bot responses (richtext is an alias for markdown, auto detects markdown features)',
14
- });
15
-
16
- export const longTextModeSchema = z.enum(['chunk', 'file']);
17
-
18
- // DingTalk 配置 Schema
19
- export const dingTalkConfigSchema = z.object({
20
- enabled: z.boolean().default(true).describe('Enable DingTalk channel'),
21
-
22
- // 凭证配置(必需)
23
- clientId: z.string().min(1, 'Client ID (AppKey) is required')
24
- .describe('DingTalk application AppKey'),
25
- clientSecret: z.string().min(1, 'Client Secret (AppSecret) is required')
26
- .describe('DingTalk application AppSecret'),
27
- robotCode: z.string().optional()
28
- .describe('Robot code (optional, defaults to clientId if not provided)'),
29
-
30
- // 私聊配置
31
- dm: z.object({
32
- enabled: z.boolean().default(true)
33
- .describe('Enable direct messages'),
34
- policy: dmPolicySchema.default('pairing')
35
- .describe(
36
- 'Access control policy:\n' +
37
- ' - disabled: No DM allowed\n' +
38
- ' - pairing: Show staffId on first contact, admin adds to allowlist\n' +
39
- ' - allowlist: Only specified users can DM\n' +
40
- ' - open: Anyone can DM (not recommended)'
41
- ),
42
- allowFrom: z.array(z.string()).default([])
43
- .describe('Allowed staff IDs (for pairing/allowlist policy)'),
44
- }).default({}),
45
-
46
- // 群聊配置
47
- groupPolicy: groupPolicySchema.default('allowlist')
48
- .describe(
49
- 'Group chat policy:\n' +
50
- ' - disabled: No groups\n' +
51
- ' - allowlist: Only specified groups\n' +
52
- ' - open: All groups'
53
- ),
54
- groupAllowlist: z.array(z.string()).default([])
55
- .describe('Allowed group conversation IDs (only used when groupPolicy is "allowlist")'),
56
- requireMention: z.boolean().default(true)
57
- .describe('Require @ mention in group chats'),
58
-
59
- // 消息格式
60
- messageFormat: messageFormatSchema.default('text')
61
- .describe(
62
- 'Message format:\n' +
63
- ' - text: Plain text (recommended, supports tables)\n' +
64
- ' - markdown: DingTalk markdown (limited support, no tables)\n' +
65
- ' - richtext: Alias for markdown (deprecated, use markdown instead)\n' +
66
- ' - auto: Auto-detect markdown features in response'
67
- ),
68
-
69
- // 思考反馈
70
- showThinking: z.boolean().default(false)
71
- .describe('Send "正在思考..." feedback before AI responds'),
72
-
73
- // 高级配置(可选)
74
- textChunkLimit: z.number().int().positive().default(2000).optional()
75
- .describe('Text chunk size limit for long messages'),
76
-
77
- // 长文本处理
78
- longTextMode: longTextModeSchema.default('chunk')
79
- .describe(
80
- 'How to handle long text messages:\n' +
81
- ' - chunk: Split into multiple messages (default, same as official channels)\n' +
82
- ' - file: Convert to .md file and send as attachment'
83
- ),
84
- longTextThreshold: z.number().int().positive().default(8000).optional()
85
- .describe('Character threshold for longTextMode=file (default 8000)'),
86
-
87
- // 每群聊覆盖配置
88
- groups: z.record(z.string(), z.object({
89
- systemPrompt: z.string().optional()
90
- .describe('Per-group extra system prompt injected as GroupSystemPrompt'),
91
- enabled: z.boolean().optional()
92
- .describe('Disable this group (false = ignore all messages)'),
93
- })).optional().default({})
94
- .describe('Per-group overrides keyed by conversationId'),
95
-
96
- // 消息聚合
97
- messageAggregation: z.boolean().default(true)
98
- .describe(
99
- 'Aggregate messages from the same sender within a short time window.\n' +
100
- 'Useful when DingTalk splits link cards into multiple messages.'
101
- ),
102
- messageAggregationDelayMs: z.number().int().positive().default(2000).optional()
103
- .describe('Time window in milliseconds to wait for additional messages (default 2000)'),
104
- }).passthrough();
105
-
106
- // 导出配置类型
107
- export type DingTalkConfig = z.infer<typeof dingTalkConfigSchema>;
108
-
109
- /**
110
- * 验证 DingTalk 配置
111
- * @param config - 原始配置对象
112
- * @returns 验证后的配置
113
- * @throws ZodError 如果配置无效
114
- */
115
- export function validateDingTalkConfig(config: unknown): DingTalkConfig {
116
- try {
117
- return dingTalkConfigSchema.parse(config);
118
- } catch (error) {
119
- if (error instanceof z.ZodError) {
120
- const formatted = error.issues.map(e => {
121
- const path = e.path.join('.');
122
- return ` - ${path || 'root'}: ${e.message}`;
123
- }).join('\n');
124
- throw new Error(`DingTalk config validation failed:\n${formatted}`);
125
- }
126
- throw error;
127
- }
128
- }
129
-
130
- /**
131
- * 安全地验证配置,返回错误而不抛出异常
132
- * @param config - 原始配置对象
133
- * @returns { success: true, data } 或 { success: false, error }
134
- */
135
- export function safeValidateDingTalkConfig(config: unknown):
136
- | { success: true; data: DingTalkConfig }
137
- | { success: false; error: string } {
138
- try {
139
- const data = dingTalkConfigSchema.parse(config);
140
- return { success: true, data };
141
- } catch (error) {
142
- if (error instanceof z.ZodError) {
143
- const formatted = error.issues.map(e => {
144
- const path = e.path.join('.');
145
- return `${path || 'root'}: ${e.message}`;
146
- }).join('; ');
147
- return { success: false, error: formatted };
148
- }
149
- return { success: false, error: String(error) };
150
- }
151
- }
1
+ import { z } from 'zod';
2
+
3
+ // 定义枚举类型
4
+ export const dmPolicySchema = z.enum(['disabled', 'pairing', 'allowlist', 'open'], {
5
+ description: 'DM access control policy',
6
+ });
7
+
8
+ export const groupPolicySchema = z.enum(['disabled', 'allowlist', 'open'], {
9
+ description: 'Group chat access control policy',
10
+ });
11
+
12
+ export const messageFormatSchema = z.enum(['text', 'markdown', 'richtext', 'auto'], {
13
+ description: 'Message format for bot responses (richtext is an alias for markdown, auto detects markdown features)',
14
+ });
15
+
16
+ export const longTextModeSchema = z.enum(['chunk', 'file']);
17
+
18
+ // Per-account config schema (everything that can be overridden per account)
19
+ export const dingTalkAccountConfigSchema = z.object({
20
+ enabled: z.boolean().default(true).describe('Enable this account'),
21
+ name: z.string().optional().describe('Display name for this account'),
22
+
23
+ // Credentials
24
+ clientId: z.string().optional()
25
+ .describe('DingTalk application AppKey'),
26
+ clientSecret: z.string().optional()
27
+ .describe('DingTalk application AppSecret'),
28
+ robotCode: z.string().optional()
29
+ .describe('Robot code (optional, defaults to clientId if not provided)'),
30
+
31
+ // DM config
32
+ dm: z.object({
33
+ enabled: z.boolean().default(true)
34
+ .describe('Enable direct messages'),
35
+ policy: dmPolicySchema.default('pairing')
36
+ .describe(
37
+ 'Access control policy:\n' +
38
+ ' - disabled: No DM allowed\n' +
39
+ ' - pairing: Show staffId on first contact, admin adds to allowlist\n' +
40
+ ' - allowlist: Only specified users can DM\n' +
41
+ ' - open: Anyone can DM (not recommended)'
42
+ ),
43
+ allowFrom: z.array(z.string()).default([])
44
+ .describe('Allowed staff IDs (for pairing/allowlist policy)'),
45
+ }).default({}),
46
+
47
+ // Group config
48
+ groupPolicy: groupPolicySchema.default('allowlist')
49
+ .describe(
50
+ 'Group chat policy:\n' +
51
+ ' - disabled: No groups\n' +
52
+ ' - allowlist: Only specified groups\n' +
53
+ ' - open: All groups'
54
+ ),
55
+ groupAllowlist: z.array(z.string()).default([])
56
+ .describe('Allowed group conversation IDs (only used when groupPolicy is "allowlist")'),
57
+ requireMention: z.boolean().default(true)
58
+ .describe('Require @ mention in group chats'),
59
+
60
+ // Message format
61
+ messageFormat: messageFormatSchema.default('text')
62
+ .describe(
63
+ 'Message format:\n' +
64
+ ' - text: Plain text (recommended, supports tables)\n' +
65
+ ' - markdown: DingTalk markdown (limited support, no tables)\n' +
66
+ ' - richtext: Alias for markdown (deprecated, use markdown instead)\n' +
67
+ ' - auto: Auto-detect markdown features in response'
68
+ ),
69
+
70
+ // Thinking feedback
71
+ showThinking: z.boolean().default(false)
72
+ .describe('Send "正在思考..." feedback before AI responds'),
73
+
74
+ // Advanced
75
+ textChunkLimit: z.number().int().positive().default(2000).optional()
76
+ .describe('Text chunk size limit for long messages'),
77
+
78
+ longTextMode: longTextModeSchema.default('chunk')
79
+ .describe(
80
+ 'How to handle long text messages:\n' +
81
+ ' - chunk: Split into multiple messages (default, same as official channels)\n' +
82
+ ' - file: Convert to .md file and send as attachment'
83
+ ),
84
+ longTextThreshold: z.number().int().positive().default(8000).optional()
85
+ .describe('Character threshold for longTextMode=file (default 8000)'),
86
+
87
+ // Per-group overrides
88
+ groups: z.record(z.string(), z.object({
89
+ systemPrompt: z.string().optional()
90
+ .describe('Per-group extra system prompt injected as GroupSystemPrompt'),
91
+ enabled: z.boolean().optional()
92
+ .describe('Disable this group (false = ignore all messages)'),
93
+ })).optional().default({})
94
+ .describe('Per-group overrides keyed by conversationId'),
95
+
96
+ // Message aggregation
97
+ messageAggregation: z.boolean().default(true)
98
+ .describe(
99
+ 'Aggregate messages from the same sender within a short time window.\n' +
100
+ 'Useful when DingTalk splits link cards into multiple messages.'
101
+ ),
102
+ messageAggregationDelayMs: z.number().int().positive().default(2000).optional()
103
+ .describe('Time window in milliseconds to wait for additional messages (default 2000)'),
104
+ }).passthrough();
105
+
106
+ // Top-level DingTalk config: account config + multi-account support
107
+ export const dingTalkConfigSchema = dingTalkAccountConfigSchema.extend({
108
+ accounts: z.record(z.string(), dingTalkAccountConfigSchema.partial().optional()).optional()
109
+ .describe('Named accounts that override top-level settings'),
110
+ defaultAccount: z.string().optional()
111
+ .describe('Default account ID (if accounts are defined)'),
112
+ });
113
+
114
+ // 导出配置类型
115
+ export type DingTalkAccountConfig = z.infer<typeof dingTalkAccountConfigSchema>;
116
+ export type DingTalkConfig = z.infer<typeof dingTalkConfigSchema>;
117
+
118
+ /**
119
+ * 验证 DingTalk 配置
120
+ * @param config - 原始配置对象
121
+ * @returns 验证后的配置
122
+ * @throws ZodError 如果配置无效
123
+ */
124
+ export function validateDingTalkConfig(config: unknown): DingTalkConfig {
125
+ try {
126
+ return dingTalkConfigSchema.parse(config);
127
+ } catch (error) {
128
+ if (error instanceof z.ZodError) {
129
+ const formatted = error.issues.map(e => {
130
+ const path = e.path.join('.');
131
+ return ` - ${path || 'root'}: ${e.message}`;
132
+ }).join('\n');
133
+ throw new Error(`DingTalk config validation failed:\n${formatted}`);
134
+ }
135
+ throw error;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Validate per-account config (partial, used for account overrides)
141
+ */
142
+ export function validateDingTalkAccountConfig(config: unknown): DingTalkAccountConfig {
143
+ return dingTalkAccountConfigSchema.parse(config);
144
+ }
145
+
146
+ /**
147
+ * 安全地验证配置,返回错误而不抛出异常
148
+ * @param config - 原始配置对象
149
+ * @returns { success: true, data } 或 { success: false, error }
150
+ */
151
+ export function safeValidateDingTalkConfig(config: unknown):
152
+ | { success: true; data: DingTalkConfig }
153
+ | { success: false; error: string } {
154
+ try {
155
+ const data = dingTalkConfigSchema.parse(config);
156
+ return { success: true, data };
157
+ } catch (error) {
158
+ if (error instanceof z.ZodError) {
159
+ const formatted = error.issues.map(e => {
160
+ const path = e.path.join('.');
161
+ return `${path || 'root'}: ${e.message}`;
162
+ }).join('; ');
163
+ return { success: false, error: formatted };
164
+ }
165
+ return { success: false, error: String(error) };
166
+ }
167
+ }
package/src/monitor.ts CHANGED
@@ -190,6 +190,32 @@ interface BufferedMessage {
190
190
  const messageBuffer = new Map<string, BufferedMessage>();
191
191
  const AGGREGATION_DELAY_MS = 2000; // 2 seconds - balance between UX and catching split messages
192
192
 
193
+ // ============================================================================
194
+ // Per-Session Message Queue - serializes dispatch to prevent concurrent
195
+ // processing of messages in the same conversation. Uses Promise chaining:
196
+ // each new message's dispatch waits for the previous one to complete.
197
+ // ============================================================================
198
+
199
+ const sessionQueues = new Map<string, Promise<void>>();
200
+ const sessionQueueLastActivity = new Map<string, number>();
201
+ const SESSION_QUEUE_TTL_MS = 5 * 60 * 1000; // 5 min
202
+
203
+ const QUEUE_BUSY_PHRASES = [
204
+ "收到,前面还有消息在处理,稍后按顺序继续。",
205
+ "当前正在处理中,你的新消息已排队,完成后马上继续。",
206
+ "我还在处理上一条,这条已记下,稍后继续。",
207
+ ];
208
+
209
+ /** Clean up expired session queues (runs periodically) */
210
+ function cleanupExpiredSessionQueues(): void {
211
+ const now = Date.now();
212
+ for (const [key, ts] of sessionQueueLastActivity) {
213
+ if (now - ts > SESSION_QUEUE_TTL_MS && !sessionQueues.has(key)) {
214
+ sessionQueueLastActivity.delete(key);
215
+ }
216
+ }
217
+ }
218
+
193
219
  function getBufferKey(msg: DingTalkRobotMessage, accountId: string): string {
194
220
  return `${accountId}:${msg.conversationId}:${msg.senderId || msg.senderStaffId}`;
195
221
  }
@@ -219,10 +245,22 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
219
245
  cleanupOldMedia();
220
246
  }, 60 * 60 * 1000); // 1 hour
221
247
 
248
+ // Schedule session queue cleanup every 60s
249
+ const queueCleanupInterval = setInterval(cleanupExpiredSessionQueues, 60_000);
250
+
222
251
  // Clean up on abort (only if abortSignal is provided)
223
252
  if (abortSignal) {
224
253
  abortSignal.addEventListener('abort', () => {
225
254
  clearInterval(cleanupInterval);
255
+ clearInterval(queueCleanupInterval);
256
+ // Only clear this account's queue entries (other accounts may still be running)
257
+ const prefix = account.accountId + ':';
258
+ for (const key of sessionQueues.keys()) {
259
+ if (key.startsWith(prefix)) sessionQueues.delete(key);
260
+ }
261
+ for (const key of sessionQueueLastActivity.keys()) {
262
+ if (key.startsWith(prefix)) sessionQueueLastActivity.delete(key);
263
+ }
226
264
  });
227
265
  }
228
266
 
@@ -243,7 +281,7 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
243
281
  const client = new DWClient({
244
282
  clientId: account.clientId,
245
283
  clientSecret: account.clientSecret,
246
- keepAlive: true, // Enable SDK ping/pong for socket-level liveness
284
+ keepAlive: false, // Disabled: SDK's 8s ping/pong terminates on unreachable gateway endpoints
247
285
  autoReconnect: false, // We manage reconnection with exponential backoff
248
286
  });
249
287
 
@@ -299,10 +337,10 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
299
337
  while (!abortSignal?.aborted) {
300
338
  try {
301
339
  await client.connect();
302
- reconnectAttempt = 0;
303
- lastActivityTime = Date.now();
340
+ const connectTime = Date.now();
341
+ lastActivityTime = connectTime;
304
342
  log?.info?.("[dingtalk:" + account.accountId + "] Stream connected");
305
- setStatus?.({ running: true, lastStartAt: Date.now() });
343
+ setStatus?.({ running: true, lastStartAt: connectTime });
306
344
 
307
345
  // Start heartbeat monitor: if no activity for 5 minutes, force disconnect to trigger reconnect.
308
346
  // The SDK's keepAlive ping/pong (8s interval) handles socket-level liveness and sets
@@ -331,6 +369,13 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
331
369
  }, { once: true });
332
370
  }
333
371
  });
372
+
373
+ // Only reset backoff if connection was stable (survived > 30s)
374
+ // This prevents rapid reconnect loops when connect() succeeds but
375
+ // the socket drops immediately (e.g. gateway returns unreachable endpoint)
376
+ if (Date.now() - connectTime > 30_000) {
377
+ reconnectAttempt = 0;
378
+ }
334
379
  } catch (err) {
335
380
  log?.warn?.("[dingtalk] Connection error: " + (err instanceof Error ? err.message : String(err)));
336
381
  }
@@ -1308,6 +1353,7 @@ async function flushMessageBuffer(bufferKey: string): Promise<void> {
1308
1353
 
1309
1354
  /**
1310
1355
  * Dispatch a message to the agent (after aggregation or immediately).
1356
+ * Enqueues into per-session queue to prevent concurrent processing.
1311
1357
  */
1312
1358
  async function dispatchMessage(params: {
1313
1359
  ctx: DingTalkMonitorContext;
@@ -1321,6 +1367,78 @@ async function dispatchMessage(params: {
1321
1367
  conversationId: string;
1322
1368
  mediaPath?: string;
1323
1369
  mediaType?: string;
1370
+ }): Promise<void> {
1371
+ const { ctx, conversationId } = params;
1372
+ const { account, log } = ctx;
1373
+
1374
+ const queueKey = `${account.accountId}:${conversationId}`;
1375
+ const isQueueBusy = sessionQueues.has(queueKey);
1376
+
1377
+ // If queue is busy, send a recallable notification so it disappears when processing starts
1378
+ let queueAckCleanup: (() => Promise<void>) | null = null;
1379
+ if (isQueueBusy) {
1380
+ const phrase = QUEUE_BUSY_PHRASES[Math.floor(Math.random() * QUEUE_BUSY_PHRASES.length)];
1381
+ log?.info?.("[dingtalk] Queue busy for " + queueKey + ", notifying user");
1382
+ try {
1383
+ if (account.clientId && account.clientSecret) {
1384
+ const robotCode = account.robotCode || account.clientId;
1385
+ const result = await sendTypingIndicator({
1386
+ clientId: account.clientId,
1387
+ clientSecret: account.clientSecret,
1388
+ robotCode,
1389
+ userId: params.isDm ? params.senderId : undefined,
1390
+ conversationId: !params.isDm ? conversationId : undefined,
1391
+ message: '⏳ ' + phrase,
1392
+ });
1393
+ if (!result.error) {
1394
+ queueAckCleanup = result.cleanup;
1395
+ }
1396
+ }
1397
+ } catch (_) { /* best-effort notification */ }
1398
+ }
1399
+
1400
+ // Enqueue: chain onto previous task
1401
+ const previousTask = sessionQueues.get(queueKey) || Promise.resolve();
1402
+ const currentTask = previousTask
1403
+ .then(async () => {
1404
+ // Recall queue-busy notification before starting actual processing
1405
+ if (queueAckCleanup) {
1406
+ try { await queueAckCleanup(); log?.info?.("[dingtalk] Queue ack recalled, starting processing"); } catch (_) {}
1407
+ }
1408
+ await dispatchMessageInternal(params);
1409
+ })
1410
+ .catch((err) => {
1411
+ log?.info?.("[dingtalk] Queued dispatch error: " + (err instanceof Error ? err.message : String(err)));
1412
+ })
1413
+ .finally(() => {
1414
+ sessionQueueLastActivity.set(queueKey, Date.now());
1415
+ // Clean up only if this is still the latest task
1416
+ if (sessionQueues.get(queueKey) === currentTask) {
1417
+ sessionQueues.delete(queueKey);
1418
+ }
1419
+ });
1420
+
1421
+ sessionQueues.set(queueKey, currentTask);
1422
+ sessionQueueLastActivity.set(queueKey, Date.now());
1423
+
1424
+ // Don't await — fire-and-forget so message buffering and SDK callback stay responsive
1425
+ }
1426
+
1427
+ /**
1428
+ * Internal dispatch: actually processes the message (typing indicator, agent call, reply).
1429
+ */
1430
+ async function dispatchMessageInternal(params: {
1431
+ ctx: DingTalkMonitorContext;
1432
+ msg: DingTalkRobotMessage;
1433
+ rawBody: string;
1434
+ replyTarget: any;
1435
+ sessionKey: string;
1436
+ isDm: boolean;
1437
+ senderId: string;
1438
+ senderName: string;
1439
+ conversationId: string;
1440
+ mediaPath?: string;
1441
+ mediaType?: string;
1324
1442
  }): Promise<void> {
1325
1443
  const { ctx, msg, rawBody, replyTarget, sessionKey, isDm, senderId, senderName, conversationId, mediaPath, mediaType } = params;
1326
1444
  const { account, cfg, log, setStatus } = ctx;
@@ -1418,7 +1536,7 @@ async function dispatchMessage(params: {
1418
1536
  RawBody: rawBody,
1419
1537
  CommandBody: rawBody,
1420
1538
  From: "dingtalk:" + senderId,
1421
- To: isDm ? ("dingtalk:dm:" + senderId) : ("dingtalk:group:" + conversationId),
1539
+ To: isDm ? ("dingtalk:" + account.accountId + ":dm:" + senderId) : ("dingtalk:" + account.accountId + ":group:" + conversationId),
1422
1540
  SessionKey: sessionKey,
1423
1541
  AccountId: account.accountId,
1424
1542
  ChatType: isDm ? "direct" : "group",
@@ -1437,15 +1555,16 @@ async function dispatchMessage(params: {
1437
1555
  GroupSystemPrompt: _fallbackGroupSystemPrompt,
1438
1556
  };
1439
1557
 
1440
- // Fire-and-forget: don't await to avoid blocking SDK callback during long agent runs
1441
- runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1558
+ // Await dispatch so per-session queue waits for reply delivery to complete
1559
+ // before starting the next queued message.
1560
+ await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1442
1561
  ctx: ctxPayload,
1443
1562
  cfg: actualCfg,
1444
1563
  dispatcherOptions: {
1445
1564
  deliver: async (payload: any) => {
1446
1565
  // Recall typing indicator on first delivery
1447
1566
  await cleanupTyping();
1448
-
1567
+
1449
1568
  log?.info?.("[dingtalk] Deliver payload keys: " + Object.keys(payload || {}).join(',') + " text?=" + (typeof payload?.text) + " markdown?=" + (typeof payload?.markdown));
1450
1569
  const textToSend = resolveDeliverText(payload, log);
1451
1570
  if (textToSend) {
@@ -1461,9 +1580,6 @@ async function dispatchMessage(params: {
1461
1580
  log?.info?.("[dingtalk] Reply error: " + err);
1462
1581
  },
1463
1582
  },
1464
- }).catch((err) => {
1465
- cleanupTyping().catch(() => {});
1466
- log?.info?.("[dingtalk] Dispatch failed: " + err);
1467
1583
  });
1468
1584
 
1469
1585
  // Record activity
@@ -1506,22 +1622,53 @@ async function dispatchWithFullPipeline(params: {
1506
1622
 
1507
1623
  let firstReplyFired = false;
1508
1624
 
1509
- // 1. Resolve agent route
1625
+ // 1. Resolve agent route via own bindings matching (like official plugin).
1626
+ // OpenClaw's resolveAgentRoute doesn't handle accountId correctly for multi-account.
1627
+ const peerId = isDm ? senderId : conversationId;
1628
+ const peerKind = isDm ? 'dm' : 'group';
1629
+ const chatType = isDm ? 'direct' : 'group';
1630
+
1631
+ let matchedAgentId: string | null = null;
1632
+ const bindings = (cfg as any)?.bindings;
1633
+ if (Array.isArray(bindings) && bindings.length > 0) {
1634
+ for (const binding of bindings) {
1635
+ const match = binding.match;
1636
+ if (!match) continue;
1637
+ if (match.channel && match.channel !== 'dingtalk') continue;
1638
+ if (match.accountId && match.accountId !== account.accountId) continue;
1639
+ if (match.peer) {
1640
+ if (match.peer.kind && match.peer.kind !== chatType) continue;
1641
+ if (match.peer.id && match.peer.id !== '*' && match.peer.id !== peerId) continue;
1642
+ }
1643
+ matchedAgentId = binding.agentId;
1644
+ break;
1645
+ }
1646
+ }
1647
+ if (!matchedAgentId) {
1648
+ matchedAgentId = (cfg as any)?.defaultAgent || 'main';
1649
+ }
1650
+
1651
+ // Use OpenClaw's resolveAgentRoute with our matched agentId to get a valid sessionKey.
1652
+ // Pass accountId-prefixed peerId to ensure session isolation between accounts.
1510
1653
  const route = rt.channel.routing.resolveAgentRoute({
1511
1654
  cfg,
1512
1655
  channel: 'dingtalk',
1513
1656
  accountId: account.accountId,
1514
- peer: { kind: isDm ? 'dm' : 'group', id: isDm ? senderId : conversationId },
1657
+ agentId: matchedAgentId,
1658
+ peer: { kind: peerKind, id: `${account.accountId}:${peerId}` },
1515
1659
  });
1660
+ const sessionKey = route.sessionKey;
1661
+
1662
+ log?.info?.(`[dingtalk] Route resolved: agentId=${matchedAgentId} sessionKey=${sessionKey} accountId=${account.accountId}`);
1516
1663
 
1517
1664
  // 2. Resolve store path
1518
- const storePath = rt.channel.session?.resolveStorePath?.(cfg?.session?.store, { agentId: route.agentId });
1665
+ const storePath = rt.channel.session?.resolveStorePath?.(cfg?.session?.store, { agentId: matchedAgentId });
1519
1666
 
1520
1667
  // 3. Get envelope format options
1521
1668
  const envelopeOptions = rt.channel.reply?.resolveEnvelopeFormatOptions?.(cfg) ?? {};
1522
1669
 
1523
1670
  // 4. Read previous timestamp for session continuity
1524
- const previousTimestamp = rt.channel.session?.readSessionUpdatedAt?.({ storePath, sessionKey: route.sessionKey });
1671
+ const previousTimestamp = rt.channel.session?.readSessionUpdatedAt?.({ storePath, sessionKey: sessionKey });
1525
1672
 
1526
1673
  // 5. Format inbound envelope
1527
1674
  const fromLabel = isDm ? `${senderName} (${senderId})` : `${msg.conversationTitle || conversationId} - ${senderName}`;
@@ -1532,7 +1679,7 @@ async function dispatchWithFullPipeline(params: {
1532
1679
  }) ?? rawBody;
1533
1680
 
1534
1681
  // 6. Finalize inbound context (includes media info)
1535
- const to = isDm ? `dingtalk:dm:${senderId}` : `dingtalk:group:${conversationId}`;
1682
+ const to = isDm ? `dingtalk:${account.accountId}:dm:${senderId}` : `dingtalk:${account.accountId}:group:${conversationId}`;
1536
1683
 
1537
1684
  // 6a. Per-group system prompt (read from account.config.groups)
1538
1685
  const groupsConfig = account?.config?.groups ?? {};
@@ -1541,7 +1688,7 @@ async function dispatchWithFullPipeline(params: {
1541
1688
 
1542
1689
  const ctx = rt.channel.reply.finalizeInboundContext({
1543
1690
  Body: body, RawBody: rawBody, CommandBody: rawBody, From: to, To: to,
1544
- SessionKey: route.sessionKey, AccountId: account.accountId,
1691
+ SessionKey: sessionKey, AccountId: account.accountId,
1545
1692
  ChatType: isDm ? 'direct' : 'group',
1546
1693
  ConversationLabel: fromLabel,
1547
1694
  GroupSubject: isDm ? undefined : (msg.conversationTitle || conversationId),
@@ -1557,7 +1704,7 @@ async function dispatchWithFullPipeline(params: {
1557
1704
  // 7. Record inbound session
1558
1705
  if (rt.channel.session?.recordInboundSession) {
1559
1706
  await rt.channel.session.recordInboundSession({
1560
- storePath, sessionKey: ctx.SessionKey || route.sessionKey, ctx,
1707
+ storePath, sessionKey: ctx.SessionKey || sessionKey, ctx,
1561
1708
  updateLastRoute: isDm ? { sessionKey: route.mainSessionKey, channel: 'dingtalk', to: senderId, accountId: account.accountId } : undefined,
1562
1709
  });
1563
1710
  }
package/src/types.ts CHANGED
@@ -72,5 +72,7 @@ export interface DingTalkChannelConfig {
72
72
  textChunkLimit?: number;
73
73
  messageFormat?: 'text' | 'markdown' | 'richtext' | 'auto';
74
74
  showThinking?: boolean;
75
+ accounts?: Record<string, Partial<DingTalkChannelConfig>>;
76
+ defaultAccount?: string;
75
77
  [key: string]: unknown;
76
78
  }