@yaoyuanchao/dingtalk 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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 +165 -18
- 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
|
@@ -190,6 +190,32 @@ interface BufferedMessage {
|
|
|
190
190
|
const messageBuffer = new Map<string, BufferedMessage>();
|
|
191
191
|
const AGGREGATION_DELAY_MS = 2000; // 2 seconds - balance between UX and catching split messages
|
|
192
192
|
|
|
193
|
+
// ============================================================================
|
|
194
|
+
// Per-Session Message Queue - serializes dispatch to prevent concurrent
|
|
195
|
+
// processing of messages in the same conversation. Uses Promise chaining:
|
|
196
|
+
// each new message's dispatch waits for the previous one to complete.
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
const sessionQueues = new Map<string, Promise<void>>();
|
|
200
|
+
const sessionQueueLastActivity = new Map<string, number>();
|
|
201
|
+
const SESSION_QUEUE_TTL_MS = 5 * 60 * 1000; // 5 min
|
|
202
|
+
|
|
203
|
+
const QUEUE_BUSY_PHRASES = [
|
|
204
|
+
"收到,前面还有消息在处理,稍后按顺序继续。",
|
|
205
|
+
"当前正在处理中,你的新消息已排队,完成后马上继续。",
|
|
206
|
+
"我还在处理上一条,这条已记下,稍后继续。",
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
/** Clean up expired session queues (runs periodically) */
|
|
210
|
+
function cleanupExpiredSessionQueues(): void {
|
|
211
|
+
const now = Date.now();
|
|
212
|
+
for (const [key, ts] of sessionQueueLastActivity) {
|
|
213
|
+
if (now - ts > SESSION_QUEUE_TTL_MS && !sessionQueues.has(key)) {
|
|
214
|
+
sessionQueueLastActivity.delete(key);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
193
219
|
function getBufferKey(msg: DingTalkRobotMessage, accountId: string): string {
|
|
194
220
|
return `${accountId}:${msg.conversationId}:${msg.senderId || msg.senderStaffId}`;
|
|
195
221
|
}
|
|
@@ -219,10 +245,22 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
|
|
|
219
245
|
cleanupOldMedia();
|
|
220
246
|
}, 60 * 60 * 1000); // 1 hour
|
|
221
247
|
|
|
248
|
+
// Schedule session queue cleanup every 60s
|
|
249
|
+
const queueCleanupInterval = setInterval(cleanupExpiredSessionQueues, 60_000);
|
|
250
|
+
|
|
222
251
|
// Clean up on abort (only if abortSignal is provided)
|
|
223
252
|
if (abortSignal) {
|
|
224
253
|
abortSignal.addEventListener('abort', () => {
|
|
225
254
|
clearInterval(cleanupInterval);
|
|
255
|
+
clearInterval(queueCleanupInterval);
|
|
256
|
+
// Only clear this account's queue entries (other accounts may still be running)
|
|
257
|
+
const prefix = account.accountId + ':';
|
|
258
|
+
for (const key of sessionQueues.keys()) {
|
|
259
|
+
if (key.startsWith(prefix)) sessionQueues.delete(key);
|
|
260
|
+
}
|
|
261
|
+
for (const key of sessionQueueLastActivity.keys()) {
|
|
262
|
+
if (key.startsWith(prefix)) sessionQueueLastActivity.delete(key);
|
|
263
|
+
}
|
|
226
264
|
});
|
|
227
265
|
}
|
|
228
266
|
|
|
@@ -243,7 +281,7 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
|
|
|
243
281
|
const client = new DWClient({
|
|
244
282
|
clientId: account.clientId,
|
|
245
283
|
clientSecret: account.clientSecret,
|
|
246
|
-
keepAlive:
|
|
284
|
+
keepAlive: false, // Disabled: SDK's 8s ping/pong terminates on unreachable gateway endpoints
|
|
247
285
|
autoReconnect: false, // We manage reconnection with exponential backoff
|
|
248
286
|
});
|
|
249
287
|
|
|
@@ -299,10 +337,10 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
|
|
|
299
337
|
while (!abortSignal?.aborted) {
|
|
300
338
|
try {
|
|
301
339
|
await client.connect();
|
|
302
|
-
|
|
303
|
-
lastActivityTime =
|
|
340
|
+
const connectTime = Date.now();
|
|
341
|
+
lastActivityTime = connectTime;
|
|
304
342
|
log?.info?.("[dingtalk:" + account.accountId + "] Stream connected");
|
|
305
|
-
setStatus?.({ running: true, lastStartAt:
|
|
343
|
+
setStatus?.({ running: true, lastStartAt: connectTime });
|
|
306
344
|
|
|
307
345
|
// Start heartbeat monitor: if no activity for 5 minutes, force disconnect to trigger reconnect.
|
|
308
346
|
// The SDK's keepAlive ping/pong (8s interval) handles socket-level liveness and sets
|
|
@@ -331,6 +369,13 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
|
|
|
331
369
|
}, { once: true });
|
|
332
370
|
}
|
|
333
371
|
});
|
|
372
|
+
|
|
373
|
+
// Only reset backoff if connection was stable (survived > 30s)
|
|
374
|
+
// This prevents rapid reconnect loops when connect() succeeds but
|
|
375
|
+
// the socket drops immediately (e.g. gateway returns unreachable endpoint)
|
|
376
|
+
if (Date.now() - connectTime > 30_000) {
|
|
377
|
+
reconnectAttempt = 0;
|
|
378
|
+
}
|
|
334
379
|
} catch (err) {
|
|
335
380
|
log?.warn?.("[dingtalk] Connection error: " + (err instanceof Error ? err.message : String(err)));
|
|
336
381
|
}
|
|
@@ -1308,6 +1353,7 @@ async function flushMessageBuffer(bufferKey: string): Promise<void> {
|
|
|
1308
1353
|
|
|
1309
1354
|
/**
|
|
1310
1355
|
* Dispatch a message to the agent (after aggregation or immediately).
|
|
1356
|
+
* Enqueues into per-session queue to prevent concurrent processing.
|
|
1311
1357
|
*/
|
|
1312
1358
|
async function dispatchMessage(params: {
|
|
1313
1359
|
ctx: DingTalkMonitorContext;
|
|
@@ -1321,6 +1367,78 @@ async function dispatchMessage(params: {
|
|
|
1321
1367
|
conversationId: string;
|
|
1322
1368
|
mediaPath?: string;
|
|
1323
1369
|
mediaType?: string;
|
|
1370
|
+
}): Promise<void> {
|
|
1371
|
+
const { ctx, conversationId } = params;
|
|
1372
|
+
const { account, log } = ctx;
|
|
1373
|
+
|
|
1374
|
+
const queueKey = `${account.accountId}:${conversationId}`;
|
|
1375
|
+
const isQueueBusy = sessionQueues.has(queueKey);
|
|
1376
|
+
|
|
1377
|
+
// If queue is busy, send a recallable notification so it disappears when processing starts
|
|
1378
|
+
let queueAckCleanup: (() => Promise<void>) | null = null;
|
|
1379
|
+
if (isQueueBusy) {
|
|
1380
|
+
const phrase = QUEUE_BUSY_PHRASES[Math.floor(Math.random() * QUEUE_BUSY_PHRASES.length)];
|
|
1381
|
+
log?.info?.("[dingtalk] Queue busy for " + queueKey + ", notifying user");
|
|
1382
|
+
try {
|
|
1383
|
+
if (account.clientId && account.clientSecret) {
|
|
1384
|
+
const robotCode = account.robotCode || account.clientId;
|
|
1385
|
+
const result = await sendTypingIndicator({
|
|
1386
|
+
clientId: account.clientId,
|
|
1387
|
+
clientSecret: account.clientSecret,
|
|
1388
|
+
robotCode,
|
|
1389
|
+
userId: params.isDm ? params.senderId : undefined,
|
|
1390
|
+
conversationId: !params.isDm ? conversationId : undefined,
|
|
1391
|
+
message: '⏳ ' + phrase,
|
|
1392
|
+
});
|
|
1393
|
+
if (!result.error) {
|
|
1394
|
+
queueAckCleanup = result.cleanup;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
} catch (_) { /* best-effort notification */ }
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Enqueue: chain onto previous task
|
|
1401
|
+
const previousTask = sessionQueues.get(queueKey) || Promise.resolve();
|
|
1402
|
+
const currentTask = previousTask
|
|
1403
|
+
.then(async () => {
|
|
1404
|
+
// Recall queue-busy notification before starting actual processing
|
|
1405
|
+
if (queueAckCleanup) {
|
|
1406
|
+
try { await queueAckCleanup(); log?.info?.("[dingtalk] Queue ack recalled, starting processing"); } catch (_) {}
|
|
1407
|
+
}
|
|
1408
|
+
await dispatchMessageInternal(params);
|
|
1409
|
+
})
|
|
1410
|
+
.catch((err) => {
|
|
1411
|
+
log?.info?.("[dingtalk] Queued dispatch error: " + (err instanceof Error ? err.message : String(err)));
|
|
1412
|
+
})
|
|
1413
|
+
.finally(() => {
|
|
1414
|
+
sessionQueueLastActivity.set(queueKey, Date.now());
|
|
1415
|
+
// Clean up only if this is still the latest task
|
|
1416
|
+
if (sessionQueues.get(queueKey) === currentTask) {
|
|
1417
|
+
sessionQueues.delete(queueKey);
|
|
1418
|
+
}
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
sessionQueues.set(queueKey, currentTask);
|
|
1422
|
+
sessionQueueLastActivity.set(queueKey, Date.now());
|
|
1423
|
+
|
|
1424
|
+
// Don't await — fire-and-forget so message buffering and SDK callback stay responsive
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* Internal dispatch: actually processes the message (typing indicator, agent call, reply).
|
|
1429
|
+
*/
|
|
1430
|
+
async function dispatchMessageInternal(params: {
|
|
1431
|
+
ctx: DingTalkMonitorContext;
|
|
1432
|
+
msg: DingTalkRobotMessage;
|
|
1433
|
+
rawBody: string;
|
|
1434
|
+
replyTarget: any;
|
|
1435
|
+
sessionKey: string;
|
|
1436
|
+
isDm: boolean;
|
|
1437
|
+
senderId: string;
|
|
1438
|
+
senderName: string;
|
|
1439
|
+
conversationId: string;
|
|
1440
|
+
mediaPath?: string;
|
|
1441
|
+
mediaType?: string;
|
|
1324
1442
|
}): Promise<void> {
|
|
1325
1443
|
const { ctx, msg, rawBody, replyTarget, sessionKey, isDm, senderId, senderName, conversationId, mediaPath, mediaType } = params;
|
|
1326
1444
|
const { account, cfg, log, setStatus } = ctx;
|
|
@@ -1418,7 +1536,7 @@ async function dispatchMessage(params: {
|
|
|
1418
1536
|
RawBody: rawBody,
|
|
1419
1537
|
CommandBody: rawBody,
|
|
1420
1538
|
From: "dingtalk:" + senderId,
|
|
1421
|
-
To: isDm ? ("dingtalk:dm:" + senderId) : ("dingtalk:group:" + conversationId),
|
|
1539
|
+
To: isDm ? ("dingtalk:" + account.accountId + ":dm:" + senderId) : ("dingtalk:" + account.accountId + ":group:" + conversationId),
|
|
1422
1540
|
SessionKey: sessionKey,
|
|
1423
1541
|
AccountId: account.accountId,
|
|
1424
1542
|
ChatType: isDm ? "direct" : "group",
|
|
@@ -1437,15 +1555,16 @@ async function dispatchMessage(params: {
|
|
|
1437
1555
|
GroupSystemPrompt: _fallbackGroupSystemPrompt,
|
|
1438
1556
|
};
|
|
1439
1557
|
|
|
1440
|
-
//
|
|
1441
|
-
|
|
1558
|
+
// Await dispatch so per-session queue waits for reply delivery to complete
|
|
1559
|
+
// before starting the next queued message.
|
|
1560
|
+
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1442
1561
|
ctx: ctxPayload,
|
|
1443
1562
|
cfg: actualCfg,
|
|
1444
1563
|
dispatcherOptions: {
|
|
1445
1564
|
deliver: async (payload: any) => {
|
|
1446
1565
|
// Recall typing indicator on first delivery
|
|
1447
1566
|
await cleanupTyping();
|
|
1448
|
-
|
|
1567
|
+
|
|
1449
1568
|
log?.info?.("[dingtalk] Deliver payload keys: " + Object.keys(payload || {}).join(',') + " text?=" + (typeof payload?.text) + " markdown?=" + (typeof payload?.markdown));
|
|
1450
1569
|
const textToSend = resolveDeliverText(payload, log);
|
|
1451
1570
|
if (textToSend) {
|
|
@@ -1461,9 +1580,6 @@ async function dispatchMessage(params: {
|
|
|
1461
1580
|
log?.info?.("[dingtalk] Reply error: " + err);
|
|
1462
1581
|
},
|
|
1463
1582
|
},
|
|
1464
|
-
}).catch((err) => {
|
|
1465
|
-
cleanupTyping().catch(() => {});
|
|
1466
|
-
log?.info?.("[dingtalk] Dispatch failed: " + err);
|
|
1467
1583
|
});
|
|
1468
1584
|
|
|
1469
1585
|
// Record activity
|
|
@@ -1506,22 +1622,53 @@ async function dispatchWithFullPipeline(params: {
|
|
|
1506
1622
|
|
|
1507
1623
|
let firstReplyFired = false;
|
|
1508
1624
|
|
|
1509
|
-
// 1. Resolve agent route
|
|
1625
|
+
// 1. Resolve agent route via own bindings matching (like official plugin).
|
|
1626
|
+
// OpenClaw's resolveAgentRoute doesn't handle accountId correctly for multi-account.
|
|
1627
|
+
const peerId = isDm ? senderId : conversationId;
|
|
1628
|
+
const peerKind = isDm ? 'dm' : 'group';
|
|
1629
|
+
const chatType = isDm ? 'direct' : 'group';
|
|
1630
|
+
|
|
1631
|
+
let matchedAgentId: string | null = null;
|
|
1632
|
+
const bindings = (cfg as any)?.bindings;
|
|
1633
|
+
if (Array.isArray(bindings) && bindings.length > 0) {
|
|
1634
|
+
for (const binding of bindings) {
|
|
1635
|
+
const match = binding.match;
|
|
1636
|
+
if (!match) continue;
|
|
1637
|
+
if (match.channel && match.channel !== 'dingtalk') continue;
|
|
1638
|
+
if (match.accountId && match.accountId !== account.accountId) continue;
|
|
1639
|
+
if (match.peer) {
|
|
1640
|
+
if (match.peer.kind && match.peer.kind !== chatType) continue;
|
|
1641
|
+
if (match.peer.id && match.peer.id !== '*' && match.peer.id !== peerId) continue;
|
|
1642
|
+
}
|
|
1643
|
+
matchedAgentId = binding.agentId;
|
|
1644
|
+
break;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
if (!matchedAgentId) {
|
|
1648
|
+
matchedAgentId = (cfg as any)?.defaultAgent || 'main';
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// Use OpenClaw's resolveAgentRoute with our matched agentId to get a valid sessionKey.
|
|
1652
|
+
// Pass accountId-prefixed peerId to ensure session isolation between accounts.
|
|
1510
1653
|
const route = rt.channel.routing.resolveAgentRoute({
|
|
1511
1654
|
cfg,
|
|
1512
1655
|
channel: 'dingtalk',
|
|
1513
1656
|
accountId: account.accountId,
|
|
1514
|
-
|
|
1657
|
+
agentId: matchedAgentId,
|
|
1658
|
+
peer: { kind: peerKind, id: `${account.accountId}:${peerId}` },
|
|
1515
1659
|
});
|
|
1660
|
+
const sessionKey = route.sessionKey;
|
|
1661
|
+
|
|
1662
|
+
log?.info?.(`[dingtalk] Route resolved: agentId=${matchedAgentId} sessionKey=${sessionKey} accountId=${account.accountId}`);
|
|
1516
1663
|
|
|
1517
1664
|
// 2. Resolve store path
|
|
1518
|
-
const storePath = rt.channel.session?.resolveStorePath?.(cfg?.session?.store, { agentId:
|
|
1665
|
+
const storePath = rt.channel.session?.resolveStorePath?.(cfg?.session?.store, { agentId: matchedAgentId });
|
|
1519
1666
|
|
|
1520
1667
|
// 3. Get envelope format options
|
|
1521
1668
|
const envelopeOptions = rt.channel.reply?.resolveEnvelopeFormatOptions?.(cfg) ?? {};
|
|
1522
1669
|
|
|
1523
1670
|
// 4. Read previous timestamp for session continuity
|
|
1524
|
-
const previousTimestamp = rt.channel.session?.readSessionUpdatedAt?.({ storePath, sessionKey:
|
|
1671
|
+
const previousTimestamp = rt.channel.session?.readSessionUpdatedAt?.({ storePath, sessionKey: sessionKey });
|
|
1525
1672
|
|
|
1526
1673
|
// 5. Format inbound envelope
|
|
1527
1674
|
const fromLabel = isDm ? `${senderName} (${senderId})` : `${msg.conversationTitle || conversationId} - ${senderName}`;
|
|
@@ -1532,7 +1679,7 @@ async function dispatchWithFullPipeline(params: {
|
|
|
1532
1679
|
}) ?? rawBody;
|
|
1533
1680
|
|
|
1534
1681
|
// 6. Finalize inbound context (includes media info)
|
|
1535
|
-
const to = isDm ? `dingtalk:dm:${senderId}` : `dingtalk:group:${conversationId}`;
|
|
1682
|
+
const to = isDm ? `dingtalk:${account.accountId}:dm:${senderId}` : `dingtalk:${account.accountId}:group:${conversationId}`;
|
|
1536
1683
|
|
|
1537
1684
|
// 6a. Per-group system prompt (read from account.config.groups)
|
|
1538
1685
|
const groupsConfig = account?.config?.groups ?? {};
|
|
@@ -1541,7 +1688,7 @@ async function dispatchWithFullPipeline(params: {
|
|
|
1541
1688
|
|
|
1542
1689
|
const ctx = rt.channel.reply.finalizeInboundContext({
|
|
1543
1690
|
Body: body, RawBody: rawBody, CommandBody: rawBody, From: to, To: to,
|
|
1544
|
-
SessionKey:
|
|
1691
|
+
SessionKey: sessionKey, AccountId: account.accountId,
|
|
1545
1692
|
ChatType: isDm ? 'direct' : 'group',
|
|
1546
1693
|
ConversationLabel: fromLabel,
|
|
1547
1694
|
GroupSubject: isDm ? undefined : (msg.conversationTitle || conversationId),
|
|
@@ -1557,7 +1704,7 @@ async function dispatchWithFullPipeline(params: {
|
|
|
1557
1704
|
// 7. Record inbound session
|
|
1558
1705
|
if (rt.channel.session?.recordInboundSession) {
|
|
1559
1706
|
await rt.channel.session.recordInboundSession({
|
|
1560
|
-
storePath, sessionKey: ctx.SessionKey ||
|
|
1707
|
+
storePath, sessionKey: ctx.SessionKey || sessionKey, ctx,
|
|
1561
1708
|
updateLastRoute: isDm ? { sessionKey: route.mainSessionKey, channel: 'dingtalk', to: senderId, accountId: account.accountId } : undefined,
|
|
1562
1709
|
});
|
|
1563
1710
|
}
|
package/src/types.ts
CHANGED
|
@@ -72,5 +72,7 @@ export interface DingTalkChannelConfig {
|
|
|
72
72
|
textChunkLimit?: number;
|
|
73
73
|
messageFormat?: 'text' | 'markdown' | 'richtext' | 'auto';
|
|
74
74
|
showThinking?: boolean;
|
|
75
|
+
accounts?: Record<string, Partial<DingTalkChannelConfig>>;
|
|
76
|
+
defaultAccount?: string;
|
|
75
77
|
[key: string]: unknown;
|
|
76
78
|
}
|