@yaoyuanchao/dingtalk 1.6.1 → 1.7.1
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/api.ts +67 -0
- package/src/channel.ts +16 -8
- package/src/config-schema.ts +167 -151
- package/src/monitor.ts +96 -34
- 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/api.ts
CHANGED
|
@@ -882,6 +882,73 @@ export async function recallGroupMessages(params: {
|
|
|
882
882
|
* Typing indicator helper - sends a "thinking" message that will be recalled
|
|
883
883
|
* Returns a cleanup function to recall the message
|
|
884
884
|
*/
|
|
885
|
+
// ============================================================================
|
|
886
|
+
// Emotion Reaction API - add/recall emoji reactions on user messages
|
|
887
|
+
// Cleaner UX than typing indicators: reaction floats on the message itself
|
|
888
|
+
// ============================================================================
|
|
889
|
+
|
|
890
|
+
export async function addEmotionReply(params: {
|
|
891
|
+
clientId: string;
|
|
892
|
+
clientSecret: string;
|
|
893
|
+
robotCode: string;
|
|
894
|
+
msgId: string;
|
|
895
|
+
conversationId: string;
|
|
896
|
+
emotionName?: string;
|
|
897
|
+
}): Promise<{ cleanup: () => Promise<void>; error?: string }> {
|
|
898
|
+
const emotionName = params.emotionName || '🤔思考中';
|
|
899
|
+
try {
|
|
900
|
+
const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
|
|
901
|
+
const res = await jsonPost(
|
|
902
|
+
`${DINGTALK_API_BASE}/robot/emotion/reply`,
|
|
903
|
+
{
|
|
904
|
+
robotCode: params.robotCode,
|
|
905
|
+
openMsgId: params.msgId,
|
|
906
|
+
openConversationId: params.conversationId,
|
|
907
|
+
emotionType: 2,
|
|
908
|
+
emotionName,
|
|
909
|
+
textEmotion: {
|
|
910
|
+
emotionId: "2659900",
|
|
911
|
+
emotionName,
|
|
912
|
+
text: emotionName,
|
|
913
|
+
backgroundId: "im_bg_1",
|
|
914
|
+
},
|
|
915
|
+
},
|
|
916
|
+
{ "x-acs-dingtalk-access-token": token },
|
|
917
|
+
);
|
|
918
|
+
|
|
919
|
+
if (res?.errcode && res.errcode !== 0) {
|
|
920
|
+
return { cleanup: async () => {}, error: `emotion reply failed: ${res.errmsg || res.errcode}` };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
return {
|
|
924
|
+
cleanup: async () => {
|
|
925
|
+
try {
|
|
926
|
+
const t = await getDingTalkAccessToken(params.clientId, params.clientSecret);
|
|
927
|
+
await jsonPost(
|
|
928
|
+
`${DINGTALK_API_BASE}/robot/emotion/recall`,
|
|
929
|
+
{
|
|
930
|
+
robotCode: params.robotCode,
|
|
931
|
+
openMsgId: params.msgId,
|
|
932
|
+
openConversationId: params.conversationId,
|
|
933
|
+
emotionType: 2,
|
|
934
|
+
emotionName,
|
|
935
|
+
textEmotion: {
|
|
936
|
+
emotionId: "2659900",
|
|
937
|
+
emotionName,
|
|
938
|
+
text: emotionName,
|
|
939
|
+
backgroundId: "im_bg_1",
|
|
940
|
+
},
|
|
941
|
+
},
|
|
942
|
+
{ "x-acs-dingtalk-access-token": t },
|
|
943
|
+
);
|
|
944
|
+
} catch (_) { /* best-effort recall */ }
|
|
945
|
+
},
|
|
946
|
+
};
|
|
947
|
+
} catch (err) {
|
|
948
|
+
return { cleanup: async () => {}, error: String(err) };
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
885
952
|
export async function sendTypingIndicator(params: {
|
|
886
953
|
clientId: string;
|
|
887
954
|
clientSecret: string;
|
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DingTalkRobotMessage, ResolvedDingTalkAccount, ExtractedMessage } from "./types.js";
|
|
2
|
-
import { sendViaSessionWebhook, sendMarkdownViaSessionWebhook, sendDingTalkRestMessage, batchGetUserInfo, downloadPicture, downloadMediaFile, cleanupOldMedia, uploadMediaFile, sendFileMessage, textToMarkdownFile, sendTypingIndicator } from "./api.js";
|
|
2
|
+
import { sendViaSessionWebhook, sendMarkdownViaSessionWebhook, sendDingTalkRestMessage, batchGetUserInfo, downloadPicture, downloadMediaFile, cleanupOldMedia, uploadMediaFile, sendFileMessage, textToMarkdownFile, sendTypingIndicator, addEmotionReply } from "./api.js";
|
|
3
3
|
import { getDingTalkRuntime } from "./runtime.js";
|
|
4
4
|
import { cacheInboundDownloadCode, getCachedDownloadCode } from "./quoted-msg-cache.js";
|
|
5
5
|
import { resolveQuotedFile } from "./quoted-file-service.js";
|
|
@@ -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
|
|
|
@@ -1442,30 +1448,55 @@ async function dispatchMessageInternal(params: {
|
|
|
1442
1448
|
// Typing indicator cleanup function (will be called after dispatch completes)
|
|
1443
1449
|
let typingCleanup: (() => Promise<void>) | null = null;
|
|
1444
1450
|
|
|
1445
|
-
// Send
|
|
1446
|
-
//
|
|
1451
|
+
// Send thinking feedback: prefer emotion reaction (clean UX, floats on message),
|
|
1452
|
+
// fall back to typing indicator (separate message) if emotion API fails or msgId unavailable.
|
|
1447
1453
|
if (account.config.typingIndicator !== false && account.clientId && account.clientSecret) {
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1454
|
+
const robotCode = account.robotCode || account.clientId;
|
|
1455
|
+
let emotionOk = false;
|
|
1456
|
+
|
|
1457
|
+
// Try emotion reaction first (requires msgId + conversationId)
|
|
1458
|
+
if (msg.msgId && conversationId) {
|
|
1459
|
+
try {
|
|
1460
|
+
const result = await addEmotionReply({
|
|
1461
|
+
clientId: account.clientId,
|
|
1462
|
+
clientSecret: account.clientSecret,
|
|
1463
|
+
robotCode,
|
|
1464
|
+
msgId: msg.msgId,
|
|
1465
|
+
conversationId,
|
|
1466
|
+
});
|
|
1467
|
+
if (!result.error) {
|
|
1468
|
+
typingCleanup = result.cleanup;
|
|
1469
|
+
emotionOk = true;
|
|
1470
|
+
log?.info?.('[dingtalk] Emotion reaction added (will be recalled on reply)');
|
|
1471
|
+
} else {
|
|
1472
|
+
log?.info?.('[dingtalk] Emotion reaction failed: ' + result.error + ', falling back to typing indicator');
|
|
1473
|
+
}
|
|
1474
|
+
} catch (err) {
|
|
1475
|
+
log?.info?.('[dingtalk] Emotion reaction error: ' + err + ', falling back to typing indicator');
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// Fallback to typing indicator (separate message)
|
|
1480
|
+
if (!emotionOk) {
|
|
1481
|
+
try {
|
|
1482
|
+
const typingMessage = account.config.typingIndicatorMessage || '⏳ 思考中...';
|
|
1483
|
+
const result = await sendTypingIndicator({
|
|
1484
|
+
clientId: account.clientId,
|
|
1485
|
+
clientSecret: account.clientSecret,
|
|
1486
|
+
robotCode,
|
|
1487
|
+
userId: isDm ? senderId : undefined,
|
|
1488
|
+
conversationId: !isDm ? conversationId : undefined,
|
|
1489
|
+
message: typingMessage,
|
|
1490
|
+
});
|
|
1491
|
+
if (!result.error) {
|
|
1492
|
+
typingCleanup = result.cleanup;
|
|
1493
|
+
log?.info?.('[dingtalk] Typing indicator sent (will be recalled on reply)');
|
|
1494
|
+
} else {
|
|
1495
|
+
log?.info?.('[dingtalk] Typing indicator failed: ' + result.error);
|
|
1496
|
+
}
|
|
1497
|
+
} catch (err) {
|
|
1498
|
+
log?.info?.('[dingtalk] Typing indicator error: ' + err);
|
|
1466
1499
|
}
|
|
1467
|
-
} catch (err) {
|
|
1468
|
-
log?.info?.('[dingtalk] Typing indicator error: ' + err);
|
|
1469
1500
|
}
|
|
1470
1501
|
}
|
|
1471
1502
|
// Legacy: Send thinking feedback (opt-in, non-recallable) - only if typingIndicator is explicitly disabled
|
|
@@ -1530,7 +1561,7 @@ async function dispatchMessageInternal(params: {
|
|
|
1530
1561
|
RawBody: rawBody,
|
|
1531
1562
|
CommandBody: rawBody,
|
|
1532
1563
|
From: "dingtalk:" + senderId,
|
|
1533
|
-
To: isDm ? ("dingtalk:dm:" + senderId) : ("dingtalk:group:" + conversationId),
|
|
1564
|
+
To: isDm ? ("dingtalk:" + account.accountId + ":dm:" + senderId) : ("dingtalk:" + account.accountId + ":group:" + conversationId),
|
|
1534
1565
|
SessionKey: sessionKey,
|
|
1535
1566
|
AccountId: account.accountId,
|
|
1536
1567
|
ChatType: isDm ? "direct" : "group",
|
|
@@ -1616,22 +1647,53 @@ async function dispatchWithFullPipeline(params: {
|
|
|
1616
1647
|
|
|
1617
1648
|
let firstReplyFired = false;
|
|
1618
1649
|
|
|
1619
|
-
// 1. Resolve agent route
|
|
1650
|
+
// 1. Resolve agent route via own bindings matching (like official plugin).
|
|
1651
|
+
// OpenClaw's resolveAgentRoute doesn't handle accountId correctly for multi-account.
|
|
1652
|
+
const peerId = isDm ? senderId : conversationId;
|
|
1653
|
+
const peerKind = isDm ? 'dm' : 'group';
|
|
1654
|
+
const chatType = isDm ? 'direct' : 'group';
|
|
1655
|
+
|
|
1656
|
+
let matchedAgentId: string | null = null;
|
|
1657
|
+
const bindings = (cfg as any)?.bindings;
|
|
1658
|
+
if (Array.isArray(bindings) && bindings.length > 0) {
|
|
1659
|
+
for (const binding of bindings) {
|
|
1660
|
+
const match = binding.match;
|
|
1661
|
+
if (!match) continue;
|
|
1662
|
+
if (match.channel && match.channel !== 'dingtalk') continue;
|
|
1663
|
+
if (match.accountId && match.accountId !== account.accountId) continue;
|
|
1664
|
+
if (match.peer) {
|
|
1665
|
+
if (match.peer.kind && match.peer.kind !== chatType) continue;
|
|
1666
|
+
if (match.peer.id && match.peer.id !== '*' && match.peer.id !== peerId) continue;
|
|
1667
|
+
}
|
|
1668
|
+
matchedAgentId = binding.agentId;
|
|
1669
|
+
break;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
if (!matchedAgentId) {
|
|
1673
|
+
matchedAgentId = (cfg as any)?.defaultAgent || 'main';
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// Use OpenClaw's resolveAgentRoute with our matched agentId to get a valid sessionKey.
|
|
1677
|
+
// Pass accountId-prefixed peerId to ensure session isolation between accounts.
|
|
1620
1678
|
const route = rt.channel.routing.resolveAgentRoute({
|
|
1621
1679
|
cfg,
|
|
1622
1680
|
channel: 'dingtalk',
|
|
1623
1681
|
accountId: account.accountId,
|
|
1624
|
-
|
|
1682
|
+
agentId: matchedAgentId,
|
|
1683
|
+
peer: { kind: peerKind, id: `${account.accountId}:${peerId}` },
|
|
1625
1684
|
});
|
|
1685
|
+
const sessionKey = route.sessionKey;
|
|
1686
|
+
|
|
1687
|
+
log?.info?.(`[dingtalk] Route resolved: agentId=${matchedAgentId} sessionKey=${sessionKey} accountId=${account.accountId}`);
|
|
1626
1688
|
|
|
1627
1689
|
// 2. Resolve store path
|
|
1628
|
-
const storePath = rt.channel.session?.resolveStorePath?.(cfg?.session?.store, { agentId:
|
|
1690
|
+
const storePath = rt.channel.session?.resolveStorePath?.(cfg?.session?.store, { agentId: matchedAgentId });
|
|
1629
1691
|
|
|
1630
1692
|
// 3. Get envelope format options
|
|
1631
1693
|
const envelopeOptions = rt.channel.reply?.resolveEnvelopeFormatOptions?.(cfg) ?? {};
|
|
1632
1694
|
|
|
1633
1695
|
// 4. Read previous timestamp for session continuity
|
|
1634
|
-
const previousTimestamp = rt.channel.session?.readSessionUpdatedAt?.({ storePath, sessionKey:
|
|
1696
|
+
const previousTimestamp = rt.channel.session?.readSessionUpdatedAt?.({ storePath, sessionKey: sessionKey });
|
|
1635
1697
|
|
|
1636
1698
|
// 5. Format inbound envelope
|
|
1637
1699
|
const fromLabel = isDm ? `${senderName} (${senderId})` : `${msg.conversationTitle || conversationId} - ${senderName}`;
|
|
@@ -1642,7 +1704,7 @@ async function dispatchWithFullPipeline(params: {
|
|
|
1642
1704
|
}) ?? rawBody;
|
|
1643
1705
|
|
|
1644
1706
|
// 6. Finalize inbound context (includes media info)
|
|
1645
|
-
const to = isDm ? `dingtalk:dm:${senderId}` : `dingtalk:group:${conversationId}`;
|
|
1707
|
+
const to = isDm ? `dingtalk:${account.accountId}:dm:${senderId}` : `dingtalk:${account.accountId}:group:${conversationId}`;
|
|
1646
1708
|
|
|
1647
1709
|
// 6a. Per-group system prompt (read from account.config.groups)
|
|
1648
1710
|
const groupsConfig = account?.config?.groups ?? {};
|
|
@@ -1651,7 +1713,7 @@ async function dispatchWithFullPipeline(params: {
|
|
|
1651
1713
|
|
|
1652
1714
|
const ctx = rt.channel.reply.finalizeInboundContext({
|
|
1653
1715
|
Body: body, RawBody: rawBody, CommandBody: rawBody, From: to, To: to,
|
|
1654
|
-
SessionKey:
|
|
1716
|
+
SessionKey: sessionKey, AccountId: account.accountId,
|
|
1655
1717
|
ChatType: isDm ? 'direct' : 'group',
|
|
1656
1718
|
ConversationLabel: fromLabel,
|
|
1657
1719
|
GroupSubject: isDm ? undefined : (msg.conversationTitle || conversationId),
|
|
@@ -1667,7 +1729,7 @@ async function dispatchWithFullPipeline(params: {
|
|
|
1667
1729
|
// 7. Record inbound session
|
|
1668
1730
|
if (rt.channel.session?.recordInboundSession) {
|
|
1669
1731
|
await rt.channel.session.recordInboundSession({
|
|
1670
|
-
storePath, sessionKey: ctx.SessionKey ||
|
|
1732
|
+
storePath, sessionKey: ctx.SessionKey || sessionKey, ctx,
|
|
1671
1733
|
updateLastRoute: isDm ? { sessionKey: route.mainSessionKey, channel: 'dingtalk', to: senderId, accountId: account.accountId } : undefined,
|
|
1672
1734
|
});
|
|
1673
1735
|
}
|
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
|
}
|