@yaoyuanchao/dingtalk 1.6.1 → 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.1",
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
@@ -253,8 +253,14 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
253
253
  abortSignal.addEventListener('abort', () => {
254
254
  clearInterval(cleanupInterval);
255
255
  clearInterval(queueCleanupInterval);
256
- sessionQueues.clear();
257
- sessionQueueLastActivity.clear();
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
+ }
258
264
  });
259
265
  }
260
266
 
@@ -275,7 +281,7 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
275
281
  const client = new DWClient({
276
282
  clientId: account.clientId,
277
283
  clientSecret: account.clientSecret,
278
- keepAlive: true, // Enable SDK ping/pong for socket-level liveness
284
+ keepAlive: false, // Disabled: SDK's 8s ping/pong terminates on unreachable gateway endpoints
279
285
  autoReconnect: false, // We manage reconnection with exponential backoff
280
286
  });
281
287
 
@@ -1530,7 +1536,7 @@ async function dispatchMessageInternal(params: {
1530
1536
  RawBody: rawBody,
1531
1537
  CommandBody: rawBody,
1532
1538
  From: "dingtalk:" + senderId,
1533
- To: isDm ? ("dingtalk:dm:" + senderId) : ("dingtalk:group:" + conversationId),
1539
+ To: isDm ? ("dingtalk:" + account.accountId + ":dm:" + senderId) : ("dingtalk:" + account.accountId + ":group:" + conversationId),
1534
1540
  SessionKey: sessionKey,
1535
1541
  AccountId: account.accountId,
1536
1542
  ChatType: isDm ? "direct" : "group",
@@ -1616,22 +1622,53 @@ async function dispatchWithFullPipeline(params: {
1616
1622
 
1617
1623
  let firstReplyFired = false;
1618
1624
 
1619
- // 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.
1620
1653
  const route = rt.channel.routing.resolveAgentRoute({
1621
1654
  cfg,
1622
1655
  channel: 'dingtalk',
1623
1656
  accountId: account.accountId,
1624
- peer: { kind: isDm ? 'dm' : 'group', id: isDm ? senderId : conversationId },
1657
+ agentId: matchedAgentId,
1658
+ peer: { kind: peerKind, id: `${account.accountId}:${peerId}` },
1625
1659
  });
1660
+ const sessionKey = route.sessionKey;
1661
+
1662
+ log?.info?.(`[dingtalk] Route resolved: agentId=${matchedAgentId} sessionKey=${sessionKey} accountId=${account.accountId}`);
1626
1663
 
1627
1664
  // 2. Resolve store path
1628
- const storePath = rt.channel.session?.resolveStorePath?.(cfg?.session?.store, { agentId: route.agentId });
1665
+ const storePath = rt.channel.session?.resolveStorePath?.(cfg?.session?.store, { agentId: matchedAgentId });
1629
1666
 
1630
1667
  // 3. Get envelope format options
1631
1668
  const envelopeOptions = rt.channel.reply?.resolveEnvelopeFormatOptions?.(cfg) ?? {};
1632
1669
 
1633
1670
  // 4. Read previous timestamp for session continuity
1634
- const previousTimestamp = rt.channel.session?.readSessionUpdatedAt?.({ storePath, sessionKey: route.sessionKey });
1671
+ const previousTimestamp = rt.channel.session?.readSessionUpdatedAt?.({ storePath, sessionKey: sessionKey });
1635
1672
 
1636
1673
  // 5. Format inbound envelope
1637
1674
  const fromLabel = isDm ? `${senderName} (${senderId})` : `${msg.conversationTitle || conversationId} - ${senderName}`;
@@ -1642,7 +1679,7 @@ async function dispatchWithFullPipeline(params: {
1642
1679
  }) ?? rawBody;
1643
1680
 
1644
1681
  // 6. Finalize inbound context (includes media info)
1645
- const to = isDm ? `dingtalk:dm:${senderId}` : `dingtalk:group:${conversationId}`;
1682
+ const to = isDm ? `dingtalk:${account.accountId}:dm:${senderId}` : `dingtalk:${account.accountId}:group:${conversationId}`;
1646
1683
 
1647
1684
  // 6a. Per-group system prompt (read from account.config.groups)
1648
1685
  const groupsConfig = account?.config?.groups ?? {};
@@ -1651,7 +1688,7 @@ async function dispatchWithFullPipeline(params: {
1651
1688
 
1652
1689
  const ctx = rt.channel.reply.finalizeInboundContext({
1653
1690
  Body: body, RawBody: rawBody, CommandBody: rawBody, From: to, To: to,
1654
- SessionKey: route.sessionKey, AccountId: account.accountId,
1691
+ SessionKey: sessionKey, AccountId: account.accountId,
1655
1692
  ChatType: isDm ? 'direct' : 'group',
1656
1693
  ConversationLabel: fromLabel,
1657
1694
  GroupSubject: isDm ? undefined : (msg.conversationTitle || conversationId),
@@ -1667,7 +1704,7 @@ async function dispatchWithFullPipeline(params: {
1667
1704
  // 7. Record inbound session
1668
1705
  if (rt.channel.session?.recordInboundSession) {
1669
1706
  await rt.channel.session.recordInboundSession({
1670
- storePath, sessionKey: ctx.SessionKey || route.sessionKey, ctx,
1707
+ storePath, sessionKey: ctx.SessionKey || sessionKey, ctx,
1671
1708
  updateLastRoute: isDm ? { sessionKey: route.mainSessionKey, channel: 'dingtalk', to: senderId, accountId: account.accountId } : undefined,
1672
1709
  });
1673
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
  }