@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 +1 -1
- package/src/accounts.ts +104 -19
- package/src/channel.ts +16 -8
- package/src/config-schema.ts +167 -151
- package/src/monitor.ts +48 -11
- package/src/types.ts +2 -0
package/package.json
CHANGED
package/src/accounts.ts
CHANGED
|
@@ -1,56 +1,141 @@
|
|
|
1
1
|
import type { ResolvedDingTalkAccount, DingTalkChannelConfig } from "./types.js";
|
|
2
|
-
import {
|
|
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 [
|
|
12
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
//
|
|
36
|
-
|
|
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 =
|
|
122
|
+
validatedConfig = validateDingTalkAccountConfig(merged);
|
|
41
123
|
|
|
42
124
|
// Determine credential source
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
142
|
+
defaultAccountId(cfg) {
|
|
143
|
+
return resolveDefaultDingTalkAccountId(cfg);
|
|
146
144
|
},
|
|
147
145
|
|
|
148
146
|
setAccountEnabled({ cfg, accountId, enabled }) {
|
|
149
147
|
const runtime = getDingTalkRuntime();
|
|
150
|
-
|
|
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
|
-
|
|
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) {
|
package/src/config-schema.ts
CHANGED
|
@@ -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
|
-
//
|
|
19
|
-
export const
|
|
20
|
-
enabled: z.boolean().default(true).describe('Enable
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
'
|
|
38
|
-
' -
|
|
39
|
-
' -
|
|
40
|
-
' -
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
'
|
|
51
|
-
' -
|
|
52
|
-
' -
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
'
|
|
64
|
-
' -
|
|
65
|
-
' -
|
|
66
|
-
' -
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
export
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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 ||
|
|
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
|
}
|