evolcore 0.0.2 → 0.0.3
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/CHANGELOG.md +44 -793
- package/dist/agents/claude-runner.js +197 -17
- package/dist/agents/codex-runner.js +46 -3
- package/dist/aun/outbox.js +8 -0
- package/dist/channels/aun.js +21 -4
- package/dist/channels/contact-bind-code.js +134 -0
- package/dist/channels/dingtalk.js +979 -149
- package/dist/channels/feishu.js +130 -54
- package/dist/channels/wecom-card.js +101 -0
- package/dist/channels/wecom-onboarding.js +82 -0
- package/dist/channels/wecom-state.js +191 -0
- package/dist/channels/wecom.js +755 -163
- package/dist/cli/agent-command.js +2 -1
- package/dist/cli/aun-commands.js +88 -33
- package/dist/cli/bench.js +2 -2
- package/dist/cli/contact.js +71 -0
- package/dist/cli/ctl-command.js +2 -2
- package/dist/cli/daemon-commands.js +77 -214
- package/dist/cli/handoff-command.js +2 -2
- package/dist/cli/help.js +9 -5
- package/dist/cli/index.js +132 -116
- package/dist/cli/init-channel.js +92 -97
- package/dist/cli/init.js +63 -26
- package/dist/cli/model.js +2 -1
- package/dist/cli/net-check.js +2 -2
- package/dist/cli/queue-command.js +30 -6
- package/dist/cli/raw-key-input.js +25 -0
- package/dist/cli/response.js +5 -6
- package/dist/cli/restart-monitor.js +25 -1
- package/dist/cli/stats.js +6 -4
- package/dist/cli/trigger-command.js +55 -15
- package/dist/cli/version.js +6 -1
- package/dist/config/builtin-role-templates.js +22 -10
- package/dist/config/builtin-roles.js +7 -1
- package/dist/config/config-manager.js +221 -17
- package/dist/config/contact-alias.js +68 -0
- package/dist/config/contact-book-store.js +454 -0
- package/dist/config/contact-book-v2-startup.js +35 -0
- package/dist/config/contact-book.js +156 -303
- package/dist/config/contact-operation-service.js +110 -0
- package/dist/config/peer-role-resolver.js +133 -54
- package/dist/config/role-ranks.js +18 -0
- package/dist/config/role-service.js +16 -19
- package/dist/config/role-store.js +16 -5
- package/dist/config/roles.js +10 -1
- package/dist/config-store.js +0 -2
- package/dist/core/auth/authorization-audit.js +10 -1
- package/dist/core/auth/operation-authorizer.js +2 -0
- package/dist/core/auth/operation-catalog.js +56 -0
- package/dist/core/command/command-handler.js +105 -10
- package/dist/core/command/connect-menu.js +374 -0
- package/dist/core/command/menu-handler.js +114 -16
- package/dist/core/command/role-menu.js +128 -29
- package/dist/core/command/slash-handler.js +28 -18
- package/dist/core/daemon-file-cache.js +12 -6
- package/dist/core/event-catalog.js +70 -0
- package/dist/core/evolagent-registry.js +0 -1
- package/dist/core/evolagent.js +20 -9
- package/dist/core/message/im-renderer.js +2 -0
- package/dist/core/message/message-bridge.js +79 -8
- package/dist/core/message/message-queue.js +10 -0
- package/dist/core/message/message-utils.js +8 -2
- package/dist/core/message/response-engine.js +269 -25
- package/dist/core/message/send-receipt.js +24 -0
- package/dist/core/message/stream-debouncer.js +11 -2
- package/dist/core/permission/tool-policy.js +47 -15
- package/dist/core/protected-paths.js +2 -0
- package/dist/core/session/session-fs-store.js +44 -3
- package/dist/core/session/session-manager.js +61 -5
- package/dist/index.js +62 -6
- package/dist/ipc.js +34 -5
- package/dist/stats/billing.js +20 -8
- package/dist/trigger/manager.js +3 -0
- package/dist/trigger/parser.js +77 -2
- package/dist/trigger/patch.js +8 -1
- package/dist/trigger/scheduler.js +3 -1
- package/dist/trigger/validation.js +13 -0
- package/dist/utils/aid-bind.js +43 -29
- package/dist/utils/instance-registry.js +14 -7
- package/dist/utils/log-writer.js +46 -0
- package/dist/utils/media-cache.js +4 -1
- package/dist/utils/model-prices.jsonl +6 -3
- package/dist/utils/restart-safety.js +31 -0
- package/dist/utils/system-memory.js +62 -0
- package/kits/docs/INDEX.md +2 -1
- package/kits/docs/evolcore/INDEX.md +5 -3
- package/kits/docs/evolcore/agent.md +9 -1
- package/kits/docs/evolcore/aid.md +5 -2
- package/kits/docs/evolcore/contact.md +57 -0
- package/kits/docs/evolcore/fs.md +9 -0
- package/kits/docs/evolcore/group.md +12 -3
- package/kits/docs/evolcore/model.md +4 -1
- package/kits/docs/evolcore/msg.md +9 -3
- package/kits/docs/evolcore/response.md +16 -21
- package/kits/docs/evolcore/rpc.md +2 -0
- package/kits/docs/evolcore/stats.md +15 -2
- package/kits/docs/evolcore/storage.md +1 -0
- package/kits/docs/evolcore/trigger.md +17 -2
- package/kits/eck_manifest.json +12 -0
- package/kits/migrations/migrate-contact-book-v2.mjs +747 -0
- package/kits/schemas/_meta.json +7 -4
- package/kits/schemas/agent-config.schema.6.json +322 -0
- package/kits/schemas/contact-book.schema.2.json +43 -0
- package/kits/schemas/relation-config.schema.5.json +47 -0
- package/kits/schemas/role-config.schema.1.json +1 -0
- package/kits/schemas/role-registry.schema.1.json +2 -2
- package/kits/templates/roles/admin.json +1 -0
- package/kits/templates/roles/member.json +1 -0
- package/kits/templates/roles/owner.json +1 -0
- package/kits/templates/roles/visitor.json +1 -0
- package/kits/templates/system-fragments/commands.md +3 -1
- package/package.json +4 -4
- package/assets/brand/evolcore/README.md +0 -19
- package/assets/brand/evolcore/evolcore-app-icon.png +0 -0
- package/assets/brand/evolcore/evolcore-app-icon.svg +0 -13
- package/assets/brand/evolcore/evolcore-brand-board.png +0 -0
- package/assets/brand/evolcore/evolcore-brand-board.svg +0 -126
- package/assets/brand/evolcore/evolcore-logo-kit.zip +0 -0
- package/assets/brand/evolcore/evolcore-logo-reverse.png +0 -0
- package/assets/brand/evolcore/evolcore-logo-reverse.svg +0 -14
- package/assets/brand/evolcore/evolcore-logo.png +0 -0
- package/assets/brand/evolcore/evolcore-logo.svg +0 -14
- package/assets/brand/evolcore/evolcore-mark.png +0 -0
- package/assets/brand/evolcore/evolcore-mark.svg +0 -10
|
@@ -1,11 +1,130 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { createHash, randomInt } from 'crypto';
|
|
2
4
|
import { logger } from '../utils/logger.js';
|
|
3
5
|
import { requireOptional } from '../utils/npm-ops.js';
|
|
4
6
|
import { middleOutputModePolicy, resolveShowActivities, showActivitiesPolicy } from '../core/channel-loader.js';
|
|
5
7
|
import { formatItemsAsText } from '../core/message/items-formatter.js';
|
|
8
|
+
import { sentReceipt, suppressedReceipt } from '../core/message/send-receipt.js';
|
|
6
9
|
import { initWelcomeManager, sendWelcomeIfNeeded } from '../utils/welcome.js';
|
|
7
10
|
import { isValidAid } from '../aun/aid/validation.js';
|
|
8
11
|
import { bindContactAlias } from '../config/contact-book.js';
|
|
12
|
+
import { resolvePaths } from '../paths.js';
|
|
13
|
+
const DINGTALK_QUEUED_EMOTION = 'Pin';
|
|
14
|
+
const DINGTALK_THINKING_EMOTION = 'BusinessTrip';
|
|
15
|
+
const DINGTALK_DONE_EMOTION = 'Done';
|
|
16
|
+
const DINGTALK_WRONG_EMOTION = 'Wrong';
|
|
17
|
+
const DINGTALK_CARD_CALLBACK_TOPIC = '/v1.0/card/instances/callback';
|
|
18
|
+
const DINGTALK_CARD_MAX_BUTTONS = 6;
|
|
19
|
+
const DINGTALK_CARD_MAX_CHECKERS = 8;
|
|
20
|
+
const DINGTALK_MAX_MESSAGE_CHARS = 18_000;
|
|
21
|
+
const DINGTALK_CARD_PENDING_TTL_MS = 24 * 60 * 60 * 1000;
|
|
22
|
+
const DINGTALK_CARD_SETTLED_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
const DINGTALK_MEDIA_HOST_SUFFIXES = [
|
|
24
|
+
'.dingtalk.com',
|
|
25
|
+
'.aliyuncs.com',
|
|
26
|
+
'.alicdn.com',
|
|
27
|
+
'.aliyun.com',
|
|
28
|
+
];
|
|
29
|
+
function asRecord(value) {
|
|
30
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
31
|
+
return {};
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function parseJsonRecord(value) {
|
|
35
|
+
if (typeof value === 'string') {
|
|
36
|
+
try {
|
|
37
|
+
return asRecord(JSON.parse(value));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return asRecord(value);
|
|
44
|
+
}
|
|
45
|
+
function splitDingtalkMessage(content) {
|
|
46
|
+
const parts = [];
|
|
47
|
+
let remaining = content;
|
|
48
|
+
while (remaining.length > DINGTALK_MAX_MESSAGE_CHARS) {
|
|
49
|
+
let splitAt = remaining.lastIndexOf('\n\n', DINGTALK_MAX_MESSAGE_CHARS);
|
|
50
|
+
if (splitAt <= 0)
|
|
51
|
+
splitAt = remaining.lastIndexOf('\n', DINGTALK_MAX_MESSAGE_CHARS);
|
|
52
|
+
if (splitAt <= 0)
|
|
53
|
+
splitAt = DINGTALK_MAX_MESSAGE_CHARS;
|
|
54
|
+
parts.push(remaining.slice(0, splitAt).trimEnd());
|
|
55
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
56
|
+
}
|
|
57
|
+
if (remaining)
|
|
58
|
+
parts.push(remaining);
|
|
59
|
+
return parts;
|
|
60
|
+
}
|
|
61
|
+
function isTrustedDingtalkMediaUrl(value) {
|
|
62
|
+
try {
|
|
63
|
+
const url = new URL(value);
|
|
64
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
65
|
+
return false;
|
|
66
|
+
const host = url.hostname.toLowerCase();
|
|
67
|
+
return DINGTALK_MEDIA_HOST_SUFFIXES.some(suffix => (host === suffix.slice(1) || host.endsWith(suffix)));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function dingtalkCardTrackId(channelName, interactionId) {
|
|
74
|
+
return `ec_${createHash('sha256').update(`${channelName || 'dingtalk'}:${interactionId}`).digest('base64url')}`;
|
|
75
|
+
}
|
|
76
|
+
export function buildDingtalkCardParamMap(interaction, state, status = '') {
|
|
77
|
+
const map = {
|
|
78
|
+
interactionId: interaction.id,
|
|
79
|
+
title: interaction.kind.title,
|
|
80
|
+
body: interaction.kind.body || '',
|
|
81
|
+
state,
|
|
82
|
+
status,
|
|
83
|
+
buttonCount: String(Math.min(interaction.kind.buttons.length, DINGTALK_CARD_MAX_BUTTONS)),
|
|
84
|
+
checkerCount: interaction.kind.kind === 'action'
|
|
85
|
+
? String(Math.min(interaction.kind.checkers?.length || 0, DINGTALK_CARD_MAX_CHECKERS))
|
|
86
|
+
: '0',
|
|
87
|
+
allowCustomInput: interaction.kind.kind === 'action' && interaction.kind.allowCustomInput ? 'true' : 'false',
|
|
88
|
+
};
|
|
89
|
+
for (let i = 0; i < DINGTALK_CARD_MAX_BUTTONS; i++) {
|
|
90
|
+
const button = interaction.kind.buttons[i];
|
|
91
|
+
map[`button_${i}_text`] = button?.label || '';
|
|
92
|
+
map[`button_${i}_style`] = button?.style || 'default';
|
|
93
|
+
map[`button_${i}_visible`] = button ? 'true' : 'false';
|
|
94
|
+
map[`button_${i}_disabled`] = state !== 'pending' || !!(button && 'disabled' in button && button.disabled)
|
|
95
|
+
? 'true'
|
|
96
|
+
: 'false';
|
|
97
|
+
}
|
|
98
|
+
const checkers = interaction.kind.kind === 'action' ? interaction.kind.checkers || [] : [];
|
|
99
|
+
for (let i = 0; i < DINGTALK_CARD_MAX_CHECKERS; i++) {
|
|
100
|
+
const checker = checkers[i];
|
|
101
|
+
map[`checker_${i}_label`] = checker?.label || '';
|
|
102
|
+
map[`checker_${i}_description`] = checker?.description || '';
|
|
103
|
+
map[`checker_${i}_visible`] = checker ? 'true' : 'false';
|
|
104
|
+
}
|
|
105
|
+
return map;
|
|
106
|
+
}
|
|
107
|
+
export function parseDingtalkCardCallback(data) {
|
|
108
|
+
const root = parseJsonRecord(data);
|
|
109
|
+
const content = parseJsonRecord(root.content);
|
|
110
|
+
const privateData = parseJsonRecord(content.cardPrivateData ?? root.cardPrivateData);
|
|
111
|
+
const actionIds = Array.isArray(privateData.actionIds)
|
|
112
|
+
? privateData.actionIds
|
|
113
|
+
: Array.isArray(content.actionIds)
|
|
114
|
+
? content.actionIds
|
|
115
|
+
: [];
|
|
116
|
+
const params = parseJsonRecord(privateData.params ?? content.params);
|
|
117
|
+
return {
|
|
118
|
+
outTrackId: root.outTrackId ?? content.outTrackId,
|
|
119
|
+
operatorId: root.userId ?? root.operatorId ?? content.userId,
|
|
120
|
+
actionId: typeof actionIds[0] === 'string'
|
|
121
|
+
? actionIds[0]
|
|
122
|
+
: typeof root.actionId === 'string'
|
|
123
|
+
? root.actionId
|
|
124
|
+
: undefined,
|
|
125
|
+
values: params,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
9
128
|
export const DINGTALK_BIND_CODE_TTL_MS = 10 * 60 * 1000;
|
|
10
129
|
export const DINGTALK_BIND_MAX_FAILED_ATTEMPTS = 5;
|
|
11
130
|
const pendingContactBinds = new Map();
|
|
@@ -66,7 +185,7 @@ export function handlePendingDingtalkContactBindMessage(ctx) {
|
|
|
66
185
|
return {
|
|
67
186
|
handled: true,
|
|
68
187
|
status: 'format',
|
|
69
|
-
reply: '请直接发送 6
|
|
188
|
+
reply: '请直接发送 6 位数字绑定码。',
|
|
70
189
|
remainingAttempts: item.maxFailedAttempts - item.failedAttempts,
|
|
71
190
|
};
|
|
72
191
|
}
|
|
@@ -79,14 +198,14 @@ export function handlePendingDingtalkContactBindMessage(ctx) {
|
|
|
79
198
|
handled: true,
|
|
80
199
|
status: 'failed',
|
|
81
200
|
remainingAttempts: 0,
|
|
82
|
-
reply: '
|
|
201
|
+
reply: '绑定码无效,本次钉钉身份绑定失败。请重新执行 ec init dingtalk 并再次扫码绑定。',
|
|
83
202
|
};
|
|
84
203
|
}
|
|
85
204
|
return {
|
|
86
205
|
handled: true,
|
|
87
206
|
status: 'wrong-code',
|
|
88
207
|
remainingAttempts: remaining,
|
|
89
|
-
reply:
|
|
208
|
+
reply: '绑定码错误,请重新发送 6 位数字绑定码。',
|
|
90
209
|
};
|
|
91
210
|
}
|
|
92
211
|
const actorId = String(ctx.actorId || '').trim();
|
|
@@ -104,7 +223,7 @@ export function handlePendingDingtalkContactBindMessage(ctx) {
|
|
|
104
223
|
return {
|
|
105
224
|
handled: true,
|
|
106
225
|
status: 'bound',
|
|
107
|
-
reply:
|
|
226
|
+
reply: `钉钉身份绑定成功:${item.channelName}:${encodeURIComponent(actorId)} -> ${item.primaryId}`,
|
|
108
227
|
};
|
|
109
228
|
}
|
|
110
229
|
catch (error) {
|
|
@@ -144,7 +263,19 @@ export class DingtalkChannel {
|
|
|
144
263
|
webhookCache = new Map();
|
|
145
264
|
conversationIdCache = new Map();
|
|
146
265
|
senderStaffIdCache = new Map();
|
|
266
|
+
messageContextCache = new Map();
|
|
267
|
+
routes = new Map();
|
|
268
|
+
queuedReactions = new Map();
|
|
269
|
+
thinkingReactions = new Map();
|
|
270
|
+
taskReactionMessages = new Map();
|
|
147
271
|
seenMessages = new Map();
|
|
272
|
+
interactionCallback;
|
|
273
|
+
interactionInvalidationCallback;
|
|
274
|
+
cardsByTrackId = new Map();
|
|
275
|
+
cardTrackIdByInteraction = new Map();
|
|
276
|
+
pendingCardsByChat = new Map();
|
|
277
|
+
cardActionsInFlight = new Set();
|
|
278
|
+
interactionSendTails = new Map();
|
|
148
279
|
cleanupInterval = null;
|
|
149
280
|
projectPathProvider = null;
|
|
150
281
|
// Welcome message manager
|
|
@@ -164,12 +295,111 @@ export class DingtalkChannel {
|
|
|
164
295
|
return false;
|
|
165
296
|
return WEBHOOK_RE.test(url);
|
|
166
297
|
}
|
|
167
|
-
isDuplicate(msgId) {
|
|
298
|
+
isDuplicate(msgId, chatId = '') {
|
|
168
299
|
if (this.seenMessages.has(msgId))
|
|
169
300
|
return true;
|
|
170
|
-
|
|
301
|
+
const ts = Date.now();
|
|
302
|
+
this.seenMessages.set(msgId, ts);
|
|
303
|
+
if (this.config.seenMsgFile) {
|
|
304
|
+
try {
|
|
305
|
+
fs.mkdirSync(path.dirname(this.config.seenMsgFile), { recursive: true });
|
|
306
|
+
fs.appendFileSync(this.config.seenMsgFile, JSON.stringify({ id: msgId, ts, chatId }) + '\n');
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
logger.debug('[DingTalk] Failed to persist seen message:', error);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
171
312
|
return false;
|
|
172
313
|
}
|
|
314
|
+
loadSeenMessages() {
|
|
315
|
+
const file = this.config.seenMsgFile;
|
|
316
|
+
if (!file)
|
|
317
|
+
return;
|
|
318
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
319
|
+
try {
|
|
320
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
321
|
+
if (!line)
|
|
322
|
+
continue;
|
|
323
|
+
const record = JSON.parse(line);
|
|
324
|
+
if (typeof record.id === 'string' && typeof record.ts === 'number' && record.ts > cutoff) {
|
|
325
|
+
this.seenMessages.set(record.id, record.ts);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
if (error?.code !== 'ENOENT')
|
|
331
|
+
logger.warn('[DingTalk] Failed to load seen messages:', error);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
rewriteSeenMessages() {
|
|
335
|
+
const file = this.config.seenMsgFile;
|
|
336
|
+
if (!file)
|
|
337
|
+
return;
|
|
338
|
+
try {
|
|
339
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
340
|
+
const records = [...this.seenMessages.entries()].map(([id, ts]) => JSON.stringify({ id, ts }));
|
|
341
|
+
if (records.length === 0)
|
|
342
|
+
fs.rmSync(file, { force: true });
|
|
343
|
+
else {
|
|
344
|
+
const tempFile = `${file}.${process.pid}.tmp`;
|
|
345
|
+
fs.writeFileSync(tempFile, records.join('\n') + '\n');
|
|
346
|
+
fs.renameSync(tempFile, file);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
logger.debug('[DingTalk] Failed to compact seen messages:', error);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
rememberRoute(chatId, route) {
|
|
354
|
+
this.routes.set(chatId, route);
|
|
355
|
+
if (route.staffId)
|
|
356
|
+
this.senderStaffIdCache.set(chatId, route.staffId);
|
|
357
|
+
this.conversationIdCache.set(chatId, route.conversationId);
|
|
358
|
+
const file = this.config.routeFile;
|
|
359
|
+
if (!file)
|
|
360
|
+
return;
|
|
361
|
+
try {
|
|
362
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
363
|
+
const serializable = Object.fromEntries(this.routes);
|
|
364
|
+
const tempFile = `${file}.${process.pid}.tmp`;
|
|
365
|
+
fs.writeFileSync(tempFile, JSON.stringify(serializable, null, 2) + '\n');
|
|
366
|
+
fs.renameSync(tempFile, file);
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
logger.debug('[DingTalk] Failed to persist routes:', error);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
loadRoutes() {
|
|
373
|
+
const file = this.config.routeFile;
|
|
374
|
+
if (!file)
|
|
375
|
+
return;
|
|
376
|
+
try {
|
|
377
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
378
|
+
for (const [chatId, raw] of Object.entries(parseJsonRecord(parsed))) {
|
|
379
|
+
const route = raw;
|
|
380
|
+
if ((route.chatType === 'private' || route.chatType === 'group') && typeof route.conversationId === 'string') {
|
|
381
|
+
this.routes.set(chatId, route);
|
|
382
|
+
if (route.staffId)
|
|
383
|
+
this.senderStaffIdCache.set(chatId, route.staffId);
|
|
384
|
+
this.conversationIdCache.set(chatId, route.conversationId);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
if (error?.code !== 'ENOENT')
|
|
390
|
+
logger.warn('[DingTalk] Failed to load routes:', error);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
acknowledgeStreamMessage(msg, response = { status: 'OK' }) {
|
|
394
|
+
if (!this.client || !msg?.headers?.messageId)
|
|
395
|
+
return;
|
|
396
|
+
try {
|
|
397
|
+
this.client.socketCallBackResponse(msg.headers.messageId, { response: JSON.stringify(response) });
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
logger.warn('[DingTalk] Stream ACK failed:', error);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
173
403
|
resolveChatId(conversationType, conversationId, senderId) {
|
|
174
404
|
return conversationType === '2' ? conversationId : senderId;
|
|
175
405
|
}
|
|
@@ -196,11 +426,22 @@ export class DingtalkChannel {
|
|
|
196
426
|
if (!clientId || !clientSecret || clientId.includes('your-') || clientSecret.includes('your-')) {
|
|
197
427
|
throw new Error('DingTalk clientId/clientSecret not configured');
|
|
198
428
|
}
|
|
199
|
-
|
|
429
|
+
this.loadSeenMessages();
|
|
430
|
+
this.loadRoutes();
|
|
431
|
+
const { DWClient, TOPIC_ROBOT, TOPIC_CARD } = await requireOptional('dingtalk-stream');
|
|
200
432
|
this.client = new DWClient({ clientId, clientSecret });
|
|
201
433
|
this.client.registerCallbackListener(TOPIC_ROBOT, async (msg) => {
|
|
202
434
|
await this.handleIncoming(msg);
|
|
203
435
|
});
|
|
436
|
+
this.client.registerCallbackListener(TOPIC_CARD || DINGTALK_CARD_CALLBACK_TOPIC, async (msg) => {
|
|
437
|
+
await this.handleCardCallbackMessage(msg);
|
|
438
|
+
});
|
|
439
|
+
if (typeof this.client.registerAllEventListener === 'function') {
|
|
440
|
+
this.client.registerAllEventListener((event) => {
|
|
441
|
+
this.handlePlatformEvent(event);
|
|
442
|
+
return { status: 'SUCCESS' };
|
|
443
|
+
});
|
|
444
|
+
}
|
|
204
445
|
await this.client.connect();
|
|
205
446
|
this.connected = true;
|
|
206
447
|
// Hourly cleanup of old dedup entries
|
|
@@ -210,6 +451,15 @@ export class DingtalkChannel {
|
|
|
210
451
|
if (ts < cutoff)
|
|
211
452
|
this.seenMessages.delete(id);
|
|
212
453
|
}
|
|
454
|
+
this.rewriteSeenMessages();
|
|
455
|
+
for (const [id, context] of this.messageContextCache) {
|
|
456
|
+
if (context.createdAt < cutoff) {
|
|
457
|
+
this.messageContextCache.delete(id);
|
|
458
|
+
this.queuedReactions.delete(id);
|
|
459
|
+
this.thinkingReactions.delete(id);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
this.cleanupCardState();
|
|
213
463
|
}, 60 * 60 * 1000);
|
|
214
464
|
logger.info('[DingTalk] Connected via Stream Mode');
|
|
215
465
|
}
|
|
@@ -228,14 +478,50 @@ export class DingtalkChannel {
|
|
|
228
478
|
}
|
|
229
479
|
logger.info('[DingTalk] Disconnected');
|
|
230
480
|
}
|
|
481
|
+
getStatus() {
|
|
482
|
+
return { connected: this.connected };
|
|
483
|
+
}
|
|
484
|
+
async reconnect() {
|
|
485
|
+
if (this.connected)
|
|
486
|
+
await this.disconnect();
|
|
487
|
+
try {
|
|
488
|
+
await this.connect();
|
|
489
|
+
return '重连成功';
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
return `重连失败: ${error instanceof Error ? error.message : String(error)}`;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
231
495
|
onMessage(handler) {
|
|
232
496
|
this.messageHandler = handler;
|
|
233
497
|
}
|
|
234
498
|
onRecall(handler) {
|
|
235
499
|
this.recallHandler = handler;
|
|
236
500
|
}
|
|
501
|
+
handlePlatformEvent(event) {
|
|
502
|
+
const data = parseJsonRecord(event?.data);
|
|
503
|
+
const bizData = parseJsonRecord(data.biz_data ?? data.bizData);
|
|
504
|
+
const eventType = String(data.biz_type ?? data.bizType ?? event?.headers?.eventType ?? '');
|
|
505
|
+
if (eventType === '260') {
|
|
506
|
+
logger.debug('[DingTalk] Robot message read event:', bizData);
|
|
507
|
+
}
|
|
508
|
+
else if (eventType === '261') {
|
|
509
|
+
// This event describes an outbound robot message being recalled. It is
|
|
510
|
+
// not the inbound user-message recall signal consumed by MessageBridge.
|
|
511
|
+
logger.debug('[DingTalk] Robot outbound message recalled:', bizData);
|
|
512
|
+
}
|
|
513
|
+
else if (eventType === '262') {
|
|
514
|
+
logger.debug('[DingTalk] Robot message reaction event:', bizData);
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
logger.debug(`[DingTalk] Ignored platform event type=${eventType || 'unknown'}`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
237
520
|
// ── Inbound message handling ───────────────────────────────────────────────
|
|
238
521
|
async handleIncoming(msg) {
|
|
522
|
+
// Stream callbacks must be acknowledged even when the payload is a
|
|
523
|
+
// duplicate, stale, malformed, or intentionally ignored by mention policy.
|
|
524
|
+
this.acknowledgeStreamMessage(msg);
|
|
239
525
|
try {
|
|
240
526
|
const data = typeof msg.data === 'string' ? JSON.parse(msg.data) : msg.data;
|
|
241
527
|
const msgId = data.msgId;
|
|
@@ -245,44 +531,65 @@ export class DingtalkChannel {
|
|
|
245
531
|
const senderNick = data.senderNick;
|
|
246
532
|
const sessionWebhook = data.sessionWebhook;
|
|
247
533
|
const msgtype = data.msgtype;
|
|
248
|
-
|
|
249
|
-
|
|
534
|
+
const chatId = this.resolveChatId(conversationType, conversationId, senderId);
|
|
535
|
+
const chatType = conversationType === '2' ? 'group' : 'private';
|
|
536
|
+
// Dedup is persisted before any policy rejection so a restart cannot
|
|
537
|
+
// re-execute or repeatedly re-evaluate the same transport message.
|
|
538
|
+
if (msgId && this.isDuplicate(msgId, chatId)) {
|
|
250
539
|
logger.debug(`[DingTalk] Duplicate message skipped: ${msgId}`);
|
|
251
540
|
return;
|
|
252
541
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
542
|
+
if (conversationId) {
|
|
543
|
+
this.rememberRoute(chatId, {
|
|
544
|
+
chatType,
|
|
545
|
+
conversationId,
|
|
546
|
+
staffId: senderId || undefined,
|
|
547
|
+
updatedAt: Date.now(),
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
const createdAt = Number(data.createAt ?? 0);
|
|
551
|
+
if (createdAt > 0 && Date.now() - createdAt > 5 * 60 * 1000) {
|
|
552
|
+
logger.warn(`[DingTalk] Dropping stale message: id=${msgId} age=${Math.round((Date.now() - createdAt) / 1000)}s`);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const isMentioned = !!(data.isInAtList || (data.atUsers && data.atUsers.length > 0));
|
|
556
|
+
const mentions = Array.isArray(data.atUsers)
|
|
557
|
+
? data.atUsers.map((item, index) => ({
|
|
558
|
+
userId: item.staffId || item.dingtalkId || '',
|
|
559
|
+
name: item.name,
|
|
560
|
+
key: `at_${index}`,
|
|
561
|
+
})).filter((item) => item.userId)
|
|
562
|
+
: [];
|
|
563
|
+
const eventMetadata = {
|
|
564
|
+
mentions: mentions.length > 0 ? mentions : undefined,
|
|
565
|
+
isMentioned,
|
|
566
|
+
topicName: typeof data.conversationTitle === 'string' ? data.conversationTitle : undefined,
|
|
567
|
+
};
|
|
260
568
|
// Group gate
|
|
261
569
|
if (conversationType === '2') {
|
|
262
|
-
|
|
263
|
-
if (!this.shouldProcessGroupMessage(conversationId, isInAtList)) {
|
|
570
|
+
if (!this.shouldProcessGroupMessage(conversationId, isMentioned)) {
|
|
264
571
|
logger.debug(`[DingTalk] Group message ignored (not mentioned): ${msgId}`);
|
|
265
572
|
return;
|
|
266
573
|
}
|
|
267
574
|
}
|
|
575
|
+
// Emotion APIs require both the inbound message ID and openConversationId.
|
|
576
|
+
// Keep the association at the channel boundary so core only handles the
|
|
577
|
+
// channel-neutral messageId.
|
|
578
|
+
if (msgId && conversationId) {
|
|
579
|
+
this.messageContextCache.set(msgId, {
|
|
580
|
+
openConversationId: conversationId,
|
|
581
|
+
createdAt: Date.now(),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
268
584
|
// Webhook cache (SSRF validated)
|
|
269
585
|
if (sessionWebhook && this.isValidWebhook(sessionWebhook)) {
|
|
270
586
|
this.webhookCache.set(chatId, sessionWebhook);
|
|
271
587
|
}
|
|
272
|
-
// ACK to prevent 60s retry
|
|
273
|
-
if (this.client && msg.headers?.messageId) {
|
|
274
|
-
try {
|
|
275
|
-
this.client.socketCallBackResponse(msg.headers.messageId, { response: JSON.stringify({ status: 'OK' }) });
|
|
276
|
-
}
|
|
277
|
-
catch (e) {
|
|
278
|
-
logger.warn('[DingTalk] ACK failed:', e);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
588
|
// Dispatch by msgtype
|
|
282
589
|
if (!this.messageHandler)
|
|
283
590
|
return;
|
|
284
591
|
// 首次交互欢迎消息(使用共享帮助函数)
|
|
285
|
-
await sendWelcomeIfNeeded(this.welcomeManager, senderId, chatId, (id, text) => this.sendMessage(id, text), 'DingTalk');
|
|
592
|
+
await sendWelcomeIfNeeded(this.welcomeManager, senderId, chatId, async (id, text) => { await this.sendMessage(id, text); }, 'DingTalk');
|
|
286
593
|
if (msgtype === 'text' || !msgtype) {
|
|
287
594
|
const text = this.extractText(data);
|
|
288
595
|
if (!text)
|
|
@@ -290,22 +597,40 @@ export class DingtalkChannel {
|
|
|
290
597
|
await this.messageHandler({
|
|
291
598
|
channelId: chatId, content: text, chatType,
|
|
292
599
|
peerId: senderId || '', peerName: senderNick, messageId: msgId,
|
|
600
|
+
...eventMetadata,
|
|
293
601
|
});
|
|
294
602
|
}
|
|
295
603
|
else if (msgtype === 'picture' || msgtype === 'image') {
|
|
296
|
-
await this.handleImageMessage(data, chatId, chatType, senderId, senderNick, msgId);
|
|
604
|
+
await this.handleImageMessage(data, chatId, chatType, senderId, senderNick, msgId, eventMetadata);
|
|
297
605
|
}
|
|
298
606
|
else if (msgtype === 'file') {
|
|
299
|
-
await this.handleFileMessage(data, chatId, chatType, senderId, senderNick, msgId);
|
|
607
|
+
await this.handleFileMessage(data, chatId, chatType, senderId, senderNick, msgId, eventMetadata);
|
|
300
608
|
}
|
|
301
609
|
else if (msgtype === 'richText') {
|
|
302
|
-
await this.handleRichTextMessage(data, chatId, chatType, senderId, senderNick, msgId);
|
|
610
|
+
await this.handleRichTextMessage(data, chatId, chatType, senderId, senderNick, msgId, eventMetadata);
|
|
611
|
+
}
|
|
612
|
+
else if (msgtype === 'audio') {
|
|
613
|
+
const recognition = data.recognition || data.content?.recognition;
|
|
614
|
+
await this.messageHandler({
|
|
615
|
+
channelId: chatId,
|
|
616
|
+
content: recognition ? `用户发送了语音:${recognition}` : '[语音消息:未提供语音识别文本]',
|
|
617
|
+
chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
|
|
618
|
+
...eventMetadata,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
else if (msgtype === 'video') {
|
|
622
|
+
const videoContent = parseJsonRecord(data.content);
|
|
623
|
+
await this.handleFileMessage({
|
|
624
|
+
...data,
|
|
625
|
+
content: { ...videoContent, fileName: videoContent.fileName || data.fileName || 'video.mp4' },
|
|
626
|
+
}, chatId, chatType, senderId, senderNick, msgId, eventMetadata);
|
|
303
627
|
}
|
|
304
628
|
else {
|
|
305
629
|
await this.messageHandler({
|
|
306
630
|
channelId: chatId,
|
|
307
631
|
content: `[不支持的消息类型: ${msgtype}]`,
|
|
308
632
|
chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
|
|
633
|
+
...eventMetadata,
|
|
309
634
|
});
|
|
310
635
|
}
|
|
311
636
|
}
|
|
@@ -314,20 +639,58 @@ export class DingtalkChannel {
|
|
|
314
639
|
}
|
|
315
640
|
}
|
|
316
641
|
// ── Inbound media handling ─────────────────────────────────────────────────
|
|
317
|
-
async
|
|
642
|
+
async resolveMessageDownloadUrl(downloadUrlOrCode) {
|
|
643
|
+
if (/^https?:\/\//i.test(downloadUrlOrCode)) {
|
|
644
|
+
if (!isTrustedDingtalkMediaUrl(downloadUrlOrCode)) {
|
|
645
|
+
throw new Error('DingTalk media URL rejected: untrusted host');
|
|
646
|
+
}
|
|
647
|
+
return downloadUrlOrCode;
|
|
648
|
+
}
|
|
649
|
+
const token = await this.client?.getAccessToken();
|
|
650
|
+
if (!token)
|
|
651
|
+
throw new Error('DingTalk access token unavailable for media download');
|
|
652
|
+
const response = await fetch('https://api.dingtalk.com/v1.0/robot/messageFiles/download', {
|
|
653
|
+
method: 'POST',
|
|
654
|
+
headers: {
|
|
655
|
+
'Content-Type': 'application/json',
|
|
656
|
+
'x-acs-dingtalk-access-token': token,
|
|
657
|
+
},
|
|
658
|
+
body: JSON.stringify({ downloadCode: downloadUrlOrCode, robotCode: this.config.clientId }),
|
|
659
|
+
signal: AbortSignal.timeout(15_000),
|
|
660
|
+
});
|
|
661
|
+
const body = await response.json().catch(() => undefined);
|
|
662
|
+
if (!response.ok || !body?.downloadUrl) {
|
|
663
|
+
throw new Error(`DingTalk media URL resolution failed: ${response.status}${body ? ` ${JSON.stringify(body)}` : ''}`);
|
|
664
|
+
}
|
|
665
|
+
if (!isTrustedDingtalkMediaUrl(body.downloadUrl)) {
|
|
666
|
+
throw new Error('DingTalk media URL resolution returned an untrusted host');
|
|
667
|
+
}
|
|
668
|
+
return body.downloadUrl;
|
|
669
|
+
}
|
|
670
|
+
async downloadMessageMedia(downloadUrlOrCode) {
|
|
671
|
+
const { safeFetch } = await import('../utils/media-cache.js');
|
|
672
|
+
const downloadUrl = await this.resolveMessageDownloadUrl(downloadUrlOrCode);
|
|
673
|
+
const host = new URL(downloadUrl).hostname;
|
|
674
|
+
return safeFetch(downloadUrl, {
|
|
675
|
+
allowedHosts: new Set([host]),
|
|
676
|
+
rejectRedirects: true,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
async handleImageMessage(data, chatId, chatType, senderId, senderNick, msgId, metadata = {}) {
|
|
318
680
|
const content = typeof data.content === 'string' ? JSON.parse(data.content) : (data.content || {});
|
|
319
|
-
const
|
|
320
|
-
if (!
|
|
681
|
+
const downloadRef = content.downloadUrl || content.downloadCode || data.downloadUrl || data.downloadCode;
|
|
682
|
+
if (!downloadRef) {
|
|
321
683
|
logger.warn('[DingTalk] Image message without downloadUrl');
|
|
322
684
|
await this.messageHandler({
|
|
323
685
|
channelId: chatId, content: '[图片下载失败:缺少下载链接]',
|
|
324
686
|
chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
|
|
687
|
+
...metadata,
|
|
325
688
|
});
|
|
326
689
|
return;
|
|
327
690
|
}
|
|
328
691
|
try {
|
|
329
|
-
const {
|
|
330
|
-
const buffer = await
|
|
692
|
+
const { validateImage } = await import('../utils/media-cache.js');
|
|
693
|
+
const buffer = await this.downloadMessageMedia(downloadRef);
|
|
331
694
|
const result = await validateImage(buffer);
|
|
332
695
|
if (result.mime) {
|
|
333
696
|
await this.messageHandler({
|
|
@@ -335,6 +698,7 @@ export class DingtalkChannel {
|
|
|
335
698
|
content: '用户发送了一张图片,请分析这张图片的内容。',
|
|
336
699
|
chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
|
|
337
700
|
images: [{ data: buffer.toString('base64'), mimeType: result.mime }],
|
|
701
|
+
...metadata,
|
|
338
702
|
});
|
|
339
703
|
}
|
|
340
704
|
else {
|
|
@@ -342,6 +706,7 @@ export class DingtalkChannel {
|
|
|
342
706
|
await this.messageHandler({
|
|
343
707
|
channelId: chatId, content: '[图片验证失败]',
|
|
344
708
|
chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
|
|
709
|
+
...metadata,
|
|
345
710
|
});
|
|
346
711
|
}
|
|
347
712
|
}
|
|
@@ -350,32 +715,35 @@ export class DingtalkChannel {
|
|
|
350
715
|
await this.messageHandler({
|
|
351
716
|
channelId: chatId, content: '[图片下载失败]',
|
|
352
717
|
chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
|
|
718
|
+
...metadata,
|
|
353
719
|
});
|
|
354
720
|
}
|
|
355
721
|
}
|
|
356
|
-
async handleFileMessage(data, chatId, chatType, senderId, senderNick, msgId) {
|
|
722
|
+
async handleFileMessage(data, chatId, chatType, senderId, senderNick, msgId, metadata = {}) {
|
|
357
723
|
const content = typeof data.content === 'string' ? JSON.parse(data.content) : (data.content || {});
|
|
358
|
-
const
|
|
359
|
-
const fileName = content.fileName || 'unknown';
|
|
360
|
-
if (!
|
|
724
|
+
const downloadRef = content.downloadUrl || content.downloadCode || data.downloadUrl || data.downloadCode;
|
|
725
|
+
const fileName = content.fileName || data.fileName || 'unknown';
|
|
726
|
+
if (!downloadRef) {
|
|
361
727
|
logger.warn('[DingTalk] File message without downloadUrl');
|
|
362
728
|
await this.messageHandler({
|
|
363
729
|
channelId: chatId, content: `[文件下载失败:缺少下载链接] ${fileName}`,
|
|
364
730
|
chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
|
|
731
|
+
...metadata,
|
|
365
732
|
});
|
|
366
733
|
return;
|
|
367
734
|
}
|
|
368
735
|
try {
|
|
369
|
-
const {
|
|
736
|
+
const { saveToUploads, sanitizeFileName } = await import('../utils/media-cache.js');
|
|
370
737
|
const projectPath = this.projectPathProvider
|
|
371
738
|
? await this.projectPathProvider(chatId)
|
|
372
739
|
: process.cwd();
|
|
373
|
-
const buffer = await
|
|
740
|
+
const buffer = await this.downloadMessageMedia(downloadRef);
|
|
374
741
|
const { filePath } = saveToUploads(buffer, sanitizeFileName(fileName), projectPath);
|
|
375
742
|
await this.messageHandler({
|
|
376
743
|
channelId: chatId,
|
|
377
744
|
content: `用户发送了文件:${fileName}\n文件已保存到:${filePath}\n请使用 Read 工具读取并分析文件内容。`,
|
|
378
745
|
chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
|
|
746
|
+
...metadata,
|
|
379
747
|
});
|
|
380
748
|
}
|
|
381
749
|
catch (error) {
|
|
@@ -383,16 +751,18 @@ export class DingtalkChannel {
|
|
|
383
751
|
await this.messageHandler({
|
|
384
752
|
channelId: chatId, content: `[文件下载失败] ${fileName}`,
|
|
385
753
|
chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
|
|
754
|
+
...metadata,
|
|
386
755
|
});
|
|
387
756
|
}
|
|
388
757
|
}
|
|
389
|
-
async handleRichTextMessage(data, chatId, chatType, senderId, senderNick, msgId) {
|
|
758
|
+
async handleRichTextMessage(data, chatId, chatType, senderId, senderNick, msgId, metadata = {}) {
|
|
390
759
|
const content = typeof data.content === 'string' ? JSON.parse(data.content) : (data.content || {});
|
|
391
760
|
const richText = content.richText;
|
|
392
761
|
if (!Array.isArray(richText)) {
|
|
393
762
|
await this.messageHandler({
|
|
394
763
|
channelId: chatId, content: '[不支持的富文本格式]',
|
|
395
764
|
chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
|
|
765
|
+
...metadata,
|
|
396
766
|
});
|
|
397
767
|
return;
|
|
398
768
|
}
|
|
@@ -402,10 +772,10 @@ export class DingtalkChannel {
|
|
|
402
772
|
if (item.type === 'text' && item.text) {
|
|
403
773
|
text += item.text;
|
|
404
774
|
}
|
|
405
|
-
else if (item.type === 'picture' && item.downloadUrl) {
|
|
775
|
+
else if (item.type === 'picture' && (item.downloadUrl || item.downloadCode)) {
|
|
406
776
|
try {
|
|
407
|
-
const {
|
|
408
|
-
const buffer = await
|
|
777
|
+
const { validateImage } = await import('../utils/media-cache.js');
|
|
778
|
+
const buffer = await this.downloadMessageMedia(item.downloadUrl || item.downloadCode);
|
|
409
779
|
const result = await validateImage(buffer);
|
|
410
780
|
if (result.mime) {
|
|
411
781
|
images.push({ data: buffer.toString('base64'), mimeType: result.mime });
|
|
@@ -421,136 +791,566 @@ export class DingtalkChannel {
|
|
|
421
791
|
channelId: chatId, content: prompt, chatType,
|
|
422
792
|
peerId: senderId || '', peerName: senderNick, messageId: msgId,
|
|
423
793
|
images: images.length > 0 ? images : undefined,
|
|
794
|
+
...metadata,
|
|
424
795
|
});
|
|
425
796
|
}
|
|
426
|
-
// ── Outbound:
|
|
797
|
+
// ── Outbound: OpenAPI sends return processQueryKey message receipts ───────
|
|
427
798
|
async sendMessage(chatId, content) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
799
|
+
if (!content.trim())
|
|
800
|
+
return [];
|
|
801
|
+
const token = await this.client?.getAccessToken();
|
|
802
|
+
if (!token)
|
|
803
|
+
throw new Error('DingTalk access token unavailable for sendMessage');
|
|
804
|
+
const messageIds = [];
|
|
805
|
+
for (const part of splitDingtalkMessage(content)) {
|
|
806
|
+
messageIds.push(await this.sendRobotMessage(chatId, token, 'sampleMarkdown', JSON.stringify({ title: 'EvolCore', text: part })));
|
|
807
|
+
}
|
|
808
|
+
return messageIds;
|
|
809
|
+
}
|
|
810
|
+
// ── Outbound: processing-state emoji reactions ────────────────────────────────
|
|
811
|
+
/** Mark a message as queued. Core only calls this while another task is running. */
|
|
812
|
+
async acknowledgeMessage(messageId) {
|
|
813
|
+
await this.ensureReaction(messageId, DINGTALK_QUEUED_EMOTION, this.queuedReactions);
|
|
814
|
+
}
|
|
815
|
+
/** Upgrade Queued to Thinking without leaving a visible state gap. */
|
|
816
|
+
async promoteAckReaction(messageId, taskId, sourceMessageIds) {
|
|
817
|
+
const messageIds = [...new Set([...(sourceMessageIds ?? []), messageId].filter(Boolean))];
|
|
818
|
+
if (taskId)
|
|
819
|
+
this.taskReactionMessages.set(taskId, messageIds);
|
|
820
|
+
// Register every in-flight reaction before yielding. A very short task may
|
|
821
|
+
// otherwise finalize while later messages in a merged batch are still
|
|
822
|
+
// untracked, leaving a late BusinessTrip behind.
|
|
823
|
+
const thinking = messageIds.map(id => (this.ensureReaction(id, DINGTALK_THINKING_EMOTION, this.thinkingReactions)));
|
|
824
|
+
await Promise.all(thinking);
|
|
825
|
+
await Promise.all(messageIds.map(id => (this.clearReaction(id, DINGTALK_QUEUED_EMOTION, this.queuedReactions))));
|
|
826
|
+
}
|
|
827
|
+
/** Clear processing reactions and replace them with Done or Wrong. */
|
|
828
|
+
async completeAckReaction(taskId, completed) {
|
|
829
|
+
const messageIds = this.taskReactionMessages.get(taskId);
|
|
830
|
+
if (!messageIds)
|
|
431
831
|
return;
|
|
832
|
+
this.taskReactionMessages.delete(taskId);
|
|
833
|
+
for (const messageId of messageIds) {
|
|
834
|
+
// Add the terminal state first so users never see an empty reaction gap.
|
|
835
|
+
let terminalAdded = await this.sendEmotion(messageId, completed ? DINGTALK_DONE_EMOTION : DINGTALK_WRONG_EMOTION, false);
|
|
836
|
+
if (!terminalAdded) {
|
|
837
|
+
terminalAdded = await this.sendEmotion(messageId, completed ? DINGTALK_DONE_EMOTION : DINGTALK_WRONG_EMOTION, false);
|
|
838
|
+
}
|
|
839
|
+
if (!terminalAdded) {
|
|
840
|
+
logger.warn(`[DingTalk] Failed to add terminal reaction for ${messageId}; preserving processing reaction`);
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
await this.clearReaction(messageId, DINGTALK_QUEUED_EMOTION, this.queuedReactions);
|
|
844
|
+
await this.clearReaction(messageId, DINGTALK_THINKING_EMOTION, this.thinkingReactions);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
async ensureReaction(messageId, emotionName, reactions) {
|
|
848
|
+
const existing = reactions.get(messageId);
|
|
849
|
+
if (existing)
|
|
850
|
+
return existing;
|
|
851
|
+
const pending = this.sendEmotion(messageId, emotionName, false)
|
|
852
|
+
.then((ok) => {
|
|
853
|
+
if (!ok)
|
|
854
|
+
reactions.delete(messageId);
|
|
855
|
+
return ok;
|
|
856
|
+
})
|
|
857
|
+
.catch(() => {
|
|
858
|
+
reactions.delete(messageId);
|
|
859
|
+
return false;
|
|
860
|
+
});
|
|
861
|
+
reactions.set(messageId, pending);
|
|
862
|
+
return pending;
|
|
863
|
+
}
|
|
864
|
+
async clearReaction(messageId, emotionName, reactions) {
|
|
865
|
+
const pending = reactions.get(messageId);
|
|
866
|
+
if (!pending)
|
|
867
|
+
return;
|
|
868
|
+
const added = await pending.catch(() => false);
|
|
869
|
+
if (added)
|
|
870
|
+
await this.sendEmotion(messageId, emotionName, true);
|
|
871
|
+
reactions.delete(messageId);
|
|
872
|
+
}
|
|
873
|
+
async sendEmotion(messageId, emotionName, recall) {
|
|
874
|
+
const context = this.messageContextCache.get(messageId);
|
|
875
|
+
if (!context) {
|
|
876
|
+
logger.debug(`[DingTalk] Cannot ${recall ? 'recall' : 'add'} emotion: no context for message ${messageId}`);
|
|
877
|
+
return false;
|
|
432
878
|
}
|
|
433
879
|
try {
|
|
434
880
|
const token = await this.client?.getAccessToken();
|
|
435
|
-
|
|
881
|
+
if (!token) {
|
|
882
|
+
logger.debug('[DingTalk] Cannot send emotion: no access token');
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
const response = await fetch(`https://api.dingtalk.com/v1.0/robot/emotion/${recall ? 'recall' : 'reply'}`, {
|
|
436
886
|
method: 'POST',
|
|
437
887
|
headers: {
|
|
438
888
|
'Content-Type': 'application/json',
|
|
439
|
-
'x-acs-dingtalk-access-token': token
|
|
889
|
+
'x-acs-dingtalk-access-token': token,
|
|
440
890
|
},
|
|
441
891
|
body: JSON.stringify({
|
|
442
|
-
|
|
443
|
-
|
|
892
|
+
robotCode: this.config.clientId,
|
|
893
|
+
openMsgId: messageId,
|
|
894
|
+
openConversationId: context.openConversationId,
|
|
895
|
+
// Pin / BusinessTrip / Done / Wrong are DingTalk built-in
|
|
896
|
+
// reactions. emotionType=2 is for custom text reactions and
|
|
897
|
+
// causes BusinessTrip to fail with system.error.
|
|
898
|
+
emotionType: 1,
|
|
899
|
+
emotionName,
|
|
444
900
|
}),
|
|
445
901
|
signal: AbortSignal.timeout(15_000),
|
|
446
902
|
});
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
logger.
|
|
903
|
+
const body = await response.json().catch(() => undefined);
|
|
904
|
+
if (!response.ok || body?.success === false) {
|
|
905
|
+
logger.warn(`[DingTalk] ${recall ? 'recall' : 'reply'} emotion ${emotionName} failed for ${messageId}: `
|
|
906
|
+
+ `${response.status}${body ? ` ${JSON.stringify(body)}` : ''}`);
|
|
907
|
+
return false;
|
|
450
908
|
}
|
|
909
|
+
return true;
|
|
451
910
|
}
|
|
452
911
|
catch (error) {
|
|
453
|
-
logger.
|
|
912
|
+
logger.debug(`[DingTalk] ${recall ? 'recall' : 'reply'} emotion ${emotionName} failed for ${messageId}: ${error?.message || error}`);
|
|
913
|
+
return false;
|
|
454
914
|
}
|
|
455
915
|
}
|
|
456
916
|
// ── Outbound: image via Open API ───────────────────────────────────────────
|
|
457
|
-
async sendImage(chatId,
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const form = new FormData();
|
|
467
|
-
form.append('type', 'image');
|
|
468
|
-
form.append('media', png, { filename: 'image.png', contentType: 'image/png' });
|
|
469
|
-
const uploadRes = await fetch(`https://oapi.dingtalk.com/media/upload?access_token=${token}`, { method: 'POST', body: form, signal: AbortSignal.timeout(30_000) });
|
|
470
|
-
const uploadData = await uploadRes.json();
|
|
471
|
-
const mediaId = uploadData?.media_id;
|
|
472
|
-
if (!mediaId) {
|
|
473
|
-
logger.error('[DingTalk] Media upload failed:', uploadData);
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
// Step 2: Send via robot API
|
|
477
|
-
await this.sendRobotMessage(chatId, token, 'sampleImageMsg', JSON.stringify({ photoURL: `@${mediaId}` }));
|
|
917
|
+
async sendImage(chatId, image) {
|
|
918
|
+
const token = await this.client?.getAccessToken();
|
|
919
|
+
if (!token)
|
|
920
|
+
throw new Error('DingTalk access token unavailable for sendImage');
|
|
921
|
+
const { validateImage } = await import('../utils/media-cache.js');
|
|
922
|
+
const imageInfo = await validateImage(image);
|
|
923
|
+
if (!imageInfo.mime) {
|
|
924
|
+
const reason = 'reason' in imageInfo ? imageInfo.reason : 'unsupported image';
|
|
925
|
+
throw new Error(`DingTalk image validation failed: ${reason}`);
|
|
478
926
|
}
|
|
479
|
-
|
|
480
|
-
|
|
927
|
+
const extension = imageInfo.mime === 'image/jpeg' ? 'jpg' : imageInfo.mime.slice('image/'.length);
|
|
928
|
+
// Step 1: Upload media
|
|
929
|
+
const FormData = (await requireOptional('form-data')).default;
|
|
930
|
+
const form = new FormData();
|
|
931
|
+
form.append('type', 'image');
|
|
932
|
+
form.append('media', image, { filename: `image.${extension}`, contentType: imageInfo.mime });
|
|
933
|
+
const uploadRes = await fetch(`https://oapi.dingtalk.com/media/upload?access_token=${token}`, { method: 'POST', body: form, signal: AbortSignal.timeout(30_000) });
|
|
934
|
+
const uploadData = await uploadRes.json();
|
|
935
|
+
const mediaId = uploadData?.media_id;
|
|
936
|
+
if (!uploadRes.ok || uploadData?.errcode || !mediaId) {
|
|
937
|
+
throw new Error(`DingTalk media upload failed: ${uploadRes.status} ${JSON.stringify(uploadData)}`);
|
|
481
938
|
}
|
|
939
|
+
// Step 2: Send via robot API
|
|
940
|
+
return [await this.sendRobotMessage(chatId, token, 'sampleImageMsg', JSON.stringify({ photoURL: `@${mediaId}` }))];
|
|
482
941
|
}
|
|
483
942
|
// ── Outbound: file via Open API ────────────────────────────────────────────
|
|
484
943
|
async sendFile(chatId, filePath) {
|
|
944
|
+
// Detect image files → route to sendImage (same pattern as Feishu)
|
|
945
|
+
const header = Buffer.alloc(4_100);
|
|
946
|
+
const fd = fs.openSync(filePath, 'r');
|
|
947
|
+
let bytesRead = 0;
|
|
485
948
|
try {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
const header = Buffer.alloc(12);
|
|
490
|
-
const fd = fs.openSync(filePath, 'r');
|
|
491
|
-
fs.readSync(fd, header, 0, 12, 0);
|
|
949
|
+
bytesRead = fs.readSync(fd, header, 0, header.length, 0);
|
|
950
|
+
}
|
|
951
|
+
finally {
|
|
492
952
|
fs.closeSync(fd);
|
|
493
|
-
const { fileTypeFromBuffer } = await import('file-type');
|
|
494
|
-
const ftype = await fileTypeFromBuffer(header);
|
|
495
|
-
if (ftype && ftype.mime.startsWith('image/')) {
|
|
496
|
-
const buf = fs.readFileSync(filePath);
|
|
497
|
-
return this.sendImage(chatId, buf);
|
|
498
|
-
}
|
|
499
|
-
const token = await this.client?.getAccessToken();
|
|
500
|
-
if (!token) {
|
|
501
|
-
logger.warn('[DingTalk] No access token for sendFile');
|
|
502
|
-
return;
|
|
503
|
-
}
|
|
504
|
-
// Step 1: Upload media
|
|
505
|
-
const FormData = (await requireOptional('form-data')).default;
|
|
506
|
-
const form = new FormData();
|
|
507
|
-
form.append('type', 'file');
|
|
508
|
-
form.append('media', fs.createReadStream(filePath), { filename: path.basename(filePath) });
|
|
509
|
-
const uploadRes = await fetch(`https://oapi.dingtalk.com/media/upload?access_token=${token}`, { method: 'POST', body: form, signal: AbortSignal.timeout(60_000) });
|
|
510
|
-
const uploadData = await uploadRes.json();
|
|
511
|
-
const mediaId = uploadData?.media_id;
|
|
512
|
-
if (!mediaId) {
|
|
513
|
-
logger.error('[DingTalk] File upload failed:', uploadData);
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
// Step 2: Send via robot API
|
|
517
|
-
const fileName = path.basename(filePath);
|
|
518
|
-
const fileType = path.extname(filePath).replace('.', '') || 'file';
|
|
519
|
-
await this.sendRobotMessage(chatId, token, 'sampleFile', JSON.stringify({ mediaId: `@${mediaId}`, fileName, fileType }));
|
|
520
953
|
}
|
|
521
|
-
|
|
522
|
-
|
|
954
|
+
const { default: imageType } = await import('image-type');
|
|
955
|
+
const ftype = await imageType(header.subarray(0, bytesRead)).catch(() => undefined);
|
|
956
|
+
if (ftype && ftype.mime.startsWith('image/')) {
|
|
957
|
+
const buf = fs.readFileSync(filePath);
|
|
958
|
+
return this.sendImage(chatId, buf);
|
|
523
959
|
}
|
|
960
|
+
const token = await this.client?.getAccessToken();
|
|
961
|
+
if (!token)
|
|
962
|
+
throw new Error('DingTalk access token unavailable for sendFile');
|
|
963
|
+
// Step 1: Upload media
|
|
964
|
+
const FormData = (await requireOptional('form-data')).default;
|
|
965
|
+
const form = new FormData();
|
|
966
|
+
form.append('type', 'file');
|
|
967
|
+
form.append('media', fs.createReadStream(filePath), { filename: path.basename(filePath) });
|
|
968
|
+
const uploadRes = await fetch(`https://oapi.dingtalk.com/media/upload?access_token=${token}`, { method: 'POST', body: form, signal: AbortSignal.timeout(60_000) });
|
|
969
|
+
const uploadData = await uploadRes.json();
|
|
970
|
+
const mediaId = uploadData?.media_id;
|
|
971
|
+
if (!uploadRes.ok || uploadData?.errcode || !mediaId) {
|
|
972
|
+
throw new Error(`DingTalk file upload failed: ${uploadRes.status} ${JSON.stringify(uploadData)}`);
|
|
973
|
+
}
|
|
974
|
+
// Step 2: Send via robot API
|
|
975
|
+
const fileName = path.basename(filePath);
|
|
976
|
+
const fileType = path.extname(filePath).replace('.', '') || 'file';
|
|
977
|
+
return [await this.sendRobotMessage(chatId, token, 'sampleFile', JSON.stringify({ mediaId: `@${mediaId}`, fileName, fileType }))];
|
|
524
978
|
}
|
|
525
979
|
// ── Robot message send helper (group vs DM) ────────────────────────────────
|
|
526
980
|
async sendRobotMessage(chatId, token, msgKey, msgParam) {
|
|
527
981
|
const headers = { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' };
|
|
528
982
|
const { clientId } = this.config;
|
|
529
983
|
// Group chatId = conversationId, DM chatId = senderId
|
|
530
|
-
const
|
|
531
|
-
const
|
|
532
|
-
|
|
984
|
+
const route = this.routes.get(chatId);
|
|
985
|
+
const cachedConvId = route?.conversationId ?? this.conversationIdCache.get(chatId);
|
|
986
|
+
const staffId = route?.staffId ?? this.senderStaffIdCache.get(chatId) ?? (route?.chatType === 'private' ? chatId : undefined);
|
|
987
|
+
let res;
|
|
988
|
+
if (route?.chatType === 'group' || cachedConvId === chatId || (!route && chatId.startsWith('cid'))) {
|
|
533
989
|
// Group: chatId is the conversationId
|
|
534
|
-
|
|
990
|
+
res = await fetch('https://api.dingtalk.com/v1.0/robot/groupMessages/send', {
|
|
535
991
|
method: 'POST', headers,
|
|
536
992
|
body: JSON.stringify({ msgKey, msgParam, openConversationId: chatId, robotCode: clientId }),
|
|
537
993
|
signal: AbortSignal.timeout(15_000),
|
|
538
994
|
});
|
|
539
|
-
if (!res.ok)
|
|
540
|
-
logger.error(`[DingTalk] Group robot send failed: ${res.status}`);
|
|
541
995
|
}
|
|
542
|
-
else if (staffId) {
|
|
996
|
+
else if (staffId || (!route && chatId)) {
|
|
543
997
|
// DM: use senderStaffId
|
|
544
|
-
const
|
|
998
|
+
const targetStaffId = staffId || chatId;
|
|
999
|
+
res = await fetch('https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend', {
|
|
545
1000
|
method: 'POST', headers,
|
|
546
|
-
body: JSON.stringify({ msgKey, msgParam, userIds: [
|
|
1001
|
+
body: JSON.stringify({ msgKey, msgParam, userIds: [targetStaffId], robotCode: clientId }),
|
|
547
1002
|
signal: AbortSignal.timeout(15_000),
|
|
548
1003
|
});
|
|
549
|
-
if (!res.ok)
|
|
550
|
-
logger.error(`[DingTalk] DM robot send failed: ${res.status}`);
|
|
551
1004
|
}
|
|
552
1005
|
else {
|
|
553
|
-
|
|
1006
|
+
throw new Error(`DingTalk route unavailable for chatId=${chatId}`);
|
|
1007
|
+
}
|
|
1008
|
+
const body = await res.json().catch(() => undefined);
|
|
1009
|
+
if (!res.ok || body?.success === false || body?.code || body?.errcode) {
|
|
1010
|
+
throw new Error(`DingTalk robot send failed: ${res.status}${body ? ` ${JSON.stringify(body)}` : ''}`);
|
|
1011
|
+
}
|
|
1012
|
+
if (Array.isArray(body?.invalidStaffIdList) && body.invalidStaffIdList.length > 0) {
|
|
1013
|
+
throw new Error(`DingTalk robot send rejected recipients: ${body.invalidStaffIdList.join(',')}`);
|
|
1014
|
+
}
|
|
1015
|
+
if (Array.isArray(body?.flowControlledStaffIdList) && body.flowControlledStaffIdList.length > 0) {
|
|
1016
|
+
throw new Error(`DingTalk robot send rate limited recipients: ${body.flowControlledStaffIdList.join(',')}`);
|
|
1017
|
+
}
|
|
1018
|
+
const messageId = body?.processQueryKey ?? body?.result?.processQueryKey;
|
|
1019
|
+
if (!messageId)
|
|
1020
|
+
throw new Error(`DingTalk robot send succeeded without processQueryKey: ${JSON.stringify(body)}`);
|
|
1021
|
+
return messageId;
|
|
1022
|
+
}
|
|
1023
|
+
onInteraction(callback) {
|
|
1024
|
+
this.interactionCallback = callback;
|
|
1025
|
+
}
|
|
1026
|
+
onInteractionInvalidated(callback) {
|
|
1027
|
+
this.interactionInvalidationCallback = callback;
|
|
1028
|
+
}
|
|
1029
|
+
async withInteractionSendLock(chatId, run) {
|
|
1030
|
+
const previous = this.interactionSendTails.get(chatId) ?? Promise.resolve();
|
|
1031
|
+
let release;
|
|
1032
|
+
const current = new Promise(resolve => { release = resolve; });
|
|
1033
|
+
this.interactionSendTails.set(chatId, current);
|
|
1034
|
+
await previous.catch(() => undefined);
|
|
1035
|
+
try {
|
|
1036
|
+
return await run();
|
|
1037
|
+
}
|
|
1038
|
+
finally {
|
|
1039
|
+
release();
|
|
1040
|
+
if (this.interactionSendTails.get(chatId) === current)
|
|
1041
|
+
this.interactionSendTails.delete(chatId);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
cardActionMap(interaction) {
|
|
1045
|
+
const result = new Map();
|
|
1046
|
+
interaction.kind.buttons.slice(0, DINGTALK_CARD_MAX_BUTTONS).forEach((button, index) => {
|
|
1047
|
+
const action = interaction.kind.kind === 'command-card'
|
|
1048
|
+
? button.command
|
|
1049
|
+
: button.key;
|
|
1050
|
+
result.set(`button_${index}`, action);
|
|
1051
|
+
result.set(`btn_${index}`, action);
|
|
1052
|
+
});
|
|
1053
|
+
if (interaction.kind.kind === 'action' && interaction.kind.allowCustomInput) {
|
|
1054
|
+
result.set('custom_submit', '_custom_input');
|
|
1055
|
+
result.set('btn_submit_custom', '_custom_input');
|
|
1056
|
+
}
|
|
1057
|
+
return result;
|
|
1058
|
+
}
|
|
1059
|
+
trackPendingCard(entry) {
|
|
1060
|
+
this.cardsByTrackId.set(entry.outTrackId, entry);
|
|
1061
|
+
this.cardTrackIdByInteraction.set(entry.interaction.id, entry.outTrackId);
|
|
1062
|
+
let pending = this.pendingCardsByChat.get(entry.chatId);
|
|
1063
|
+
if (!pending) {
|
|
1064
|
+
pending = new Set();
|
|
1065
|
+
this.pendingCardsByChat.set(entry.chatId, pending);
|
|
1066
|
+
}
|
|
1067
|
+
pending.add(entry.outTrackId);
|
|
1068
|
+
}
|
|
1069
|
+
untrackPendingCard(entry) {
|
|
1070
|
+
const pending = this.pendingCardsByChat.get(entry.chatId);
|
|
1071
|
+
pending?.delete(entry.outTrackId);
|
|
1072
|
+
if (pending?.size === 0)
|
|
1073
|
+
this.pendingCardsByChat.delete(entry.chatId);
|
|
1074
|
+
}
|
|
1075
|
+
deleteCardEntry(entry) {
|
|
1076
|
+
this.untrackPendingCard(entry);
|
|
1077
|
+
this.cardsByTrackId.delete(entry.outTrackId);
|
|
1078
|
+
if (this.cardTrackIdByInteraction.get(entry.interaction.id) === entry.outTrackId) {
|
|
1079
|
+
this.cardTrackIdByInteraction.delete(entry.interaction.id);
|
|
1080
|
+
}
|
|
1081
|
+
this.cardActionsInFlight.delete(entry.outTrackId);
|
|
1082
|
+
}
|
|
1083
|
+
/** Bound in-memory card metadata even for interactions without expiresAt. */
|
|
1084
|
+
cleanupCardState(now = Date.now()) {
|
|
1085
|
+
for (const entry of [...this.cardsByTrackId.values()]) {
|
|
1086
|
+
if (entry.settledAt) {
|
|
1087
|
+
if (entry.settledAt <= now - DINGTALK_CARD_SETTLED_RETENTION_MS) {
|
|
1088
|
+
this.deleteCardEntry(entry);
|
|
1089
|
+
}
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1092
|
+
const expired = (entry.interaction.expiresAt != null && entry.interaction.expiresAt <= now)
|
|
1093
|
+
|| entry.createdAt <= now - DINGTALK_CARD_PENDING_TTL_MS;
|
|
1094
|
+
if (!expired)
|
|
1095
|
+
continue;
|
|
1096
|
+
entry.invalidated = true;
|
|
1097
|
+
entry.invalidatedReason = 'expired';
|
|
1098
|
+
entry.settledAt = now;
|
|
1099
|
+
this.untrackPendingCard(entry);
|
|
1100
|
+
if (!this.cardActionsInFlight.has(entry.outTrackId)) {
|
|
1101
|
+
void this.updateCard(entry, 'invalid', '审批已超时').catch(error => {
|
|
1102
|
+
logger.warn(`[DingTalk] Failed to expire stale card ${entry.outTrackId}:`, error);
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
cardResponse(entry, state, status) {
|
|
1108
|
+
return {
|
|
1109
|
+
cardData: { cardParamMap: buildDingtalkCardParamMap(entry.interaction, state, status) },
|
|
1110
|
+
cardUpdateOptions: { updateCardDataByKey: false, updatePrivateDataByKey: true },
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
privateCardFeedback(message) {
|
|
1114
|
+
return {
|
|
1115
|
+
userPrivateData: { cardParamMap: { feedback: message } },
|
|
1116
|
+
cardUpdateOptions: { updatePrivateDataByKey: true },
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
async updateCard(entry, state, status) {
|
|
1120
|
+
const token = await this.client?.getAccessToken();
|
|
1121
|
+
if (!token)
|
|
1122
|
+
throw new Error('DingTalk access token unavailable for card update');
|
|
1123
|
+
const response = await fetch('https://api.dingtalk.com/v1.0/card/instances', {
|
|
1124
|
+
method: 'PUT',
|
|
1125
|
+
headers: {
|
|
1126
|
+
'Content-Type': 'application/json',
|
|
1127
|
+
'x-acs-dingtalk-access-token': token,
|
|
1128
|
+
},
|
|
1129
|
+
body: JSON.stringify({
|
|
1130
|
+
outTrackId: entry.outTrackId,
|
|
1131
|
+
cardData: { cardParamMap: buildDingtalkCardParamMap(entry.interaction, state, status) },
|
|
1132
|
+
cardUpdateOptions: { updateCardDataByKey: false, updatePrivateDataByKey: false },
|
|
1133
|
+
}),
|
|
1134
|
+
signal: AbortSignal.timeout(15_000),
|
|
1135
|
+
});
|
|
1136
|
+
const body = await response.json().catch(() => undefined);
|
|
1137
|
+
if (!response.ok || body?.success === false || body?.code || body?.errcode) {
|
|
1138
|
+
throw new Error(`DingTalk card update failed: ${response.status}${body ? ` ${JSON.stringify(body)}` : ''}`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
async invalidateInteraction(interactionId, reason = 'cancelled') {
|
|
1142
|
+
const trackId = this.cardTrackIdByInteraction.get(interactionId);
|
|
1143
|
+
const entry = trackId ? this.cardsByTrackId.get(trackId) : undefined;
|
|
1144
|
+
if (!entry || entry.resolved || entry.invalidated)
|
|
1145
|
+
return;
|
|
1146
|
+
entry.invalidated = true;
|
|
1147
|
+
entry.invalidatedReason = reason;
|
|
1148
|
+
entry.settledAt = Date.now();
|
|
1149
|
+
this.untrackPendingCard(entry);
|
|
1150
|
+
// Card callbacks must respond with cardData; DingTalk explicitly forbids
|
|
1151
|
+
// calling the update API while the callback is still being handled.
|
|
1152
|
+
if (this.cardActionsInFlight.has(entry.outTrackId))
|
|
1153
|
+
return;
|
|
1154
|
+
const status = reason === 'expired' ? '审批已超时' : reason === 'superseded' ? '已有新的交互请求' : '卡片已失效';
|
|
1155
|
+
await this.updateCard(entry, 'invalid', status).catch(error => {
|
|
1156
|
+
logger.warn(`[DingTalk] Failed to invalidate card ${entry.outTrackId}:`, error);
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
async invalidatePendingCards(chatId, trackIds) {
|
|
1160
|
+
const pending = trackIds ?? [...(this.pendingCardsByChat.get(chatId) ?? [])];
|
|
1161
|
+
for (const trackId of pending) {
|
|
1162
|
+
const entry = this.cardsByTrackId.get(trackId);
|
|
1163
|
+
if (!entry || entry.resolved || entry.invalidated)
|
|
1164
|
+
continue;
|
|
1165
|
+
if (this.interactionInvalidationCallback) {
|
|
1166
|
+
try {
|
|
1167
|
+
await this.interactionInvalidationCallback(entry.interaction.id, 'superseded');
|
|
1168
|
+
}
|
|
1169
|
+
catch (error) {
|
|
1170
|
+
logger.warn('[DingTalk] Failed to cancel superseded interaction:', error);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
await this.invalidateInteraction(entry.interaction.id, 'superseded');
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
async sendInteraction(chatId, interaction) {
|
|
1177
|
+
return this.withInteractionSendLock(chatId, async () => {
|
|
1178
|
+
if (!this.config.cardTemplateId)
|
|
1179
|
+
return false;
|
|
1180
|
+
if (interaction.kind.buttons.length > DINGTALK_CARD_MAX_BUTTONS) {
|
|
1181
|
+
logger.warn(`[DingTalk] Interaction ${interaction.id} has too many buttons for the configured card template`);
|
|
1182
|
+
return false;
|
|
1183
|
+
}
|
|
1184
|
+
if (interaction.kind.kind === 'action' && (interaction.kind.checkers?.length || 0) > DINGTALK_CARD_MAX_CHECKERS) {
|
|
1185
|
+
logger.warn(`[DingTalk] Interaction ${interaction.id} has too many checkers for the configured card template`);
|
|
1186
|
+
return false;
|
|
1187
|
+
}
|
|
1188
|
+
const previousPendingCards = [...(this.pendingCardsByChat.get(chatId) ?? [])];
|
|
1189
|
+
const route = this.routes.get(chatId);
|
|
1190
|
+
const isGroup = route?.chatType === 'group' || (!route && chatId.startsWith('cid'));
|
|
1191
|
+
const staffId = route?.staffId || (!isGroup ? chatId : undefined);
|
|
1192
|
+
if (!isGroup && !staffId)
|
|
1193
|
+
return false;
|
|
1194
|
+
const token = await this.client?.getAccessToken();
|
|
1195
|
+
if (!token)
|
|
1196
|
+
throw new Error('DingTalk access token unavailable for card send');
|
|
1197
|
+
const outTrackId = dingtalkCardTrackId(this.channelName, interaction.id);
|
|
1198
|
+
const conversationId = route?.conversationId || chatId;
|
|
1199
|
+
const openSpaceId = isGroup
|
|
1200
|
+
? `dtv1.card//IM_GROUP.${conversationId}`
|
|
1201
|
+
: `dtv1.card//IM_ROBOT.${staffId}`;
|
|
1202
|
+
const payload = {
|
|
1203
|
+
cardTemplateId: this.config.cardTemplateId,
|
|
1204
|
+
outTrackId,
|
|
1205
|
+
callbackType: 'STREAM',
|
|
1206
|
+
openSpaceId,
|
|
1207
|
+
cardData: { cardParamMap: buildDingtalkCardParamMap(interaction, 'pending') },
|
|
1208
|
+
};
|
|
1209
|
+
if (isGroup)
|
|
1210
|
+
payload.imGroupOpenDeliverModel = { robotCode: this.config.clientId };
|
|
1211
|
+
else
|
|
1212
|
+
payload.imRobotOpenDeliverModel = { robotCode: this.config.clientId };
|
|
1213
|
+
const response = await fetch('https://api.dingtalk.com/v1.0/card/instances/createAndDeliver', {
|
|
1214
|
+
method: 'POST',
|
|
1215
|
+
headers: {
|
|
1216
|
+
'Content-Type': 'application/json',
|
|
1217
|
+
'x-acs-dingtalk-access-token': token,
|
|
1218
|
+
},
|
|
1219
|
+
body: JSON.stringify(payload),
|
|
1220
|
+
signal: AbortSignal.timeout(15_000),
|
|
1221
|
+
});
|
|
1222
|
+
const body = await response.json().catch(() => undefined);
|
|
1223
|
+
if (!response.ok || body?.success === false || body?.code || body?.errcode) {
|
|
1224
|
+
throw new Error(`DingTalk card send failed: ${response.status}${body ? ` ${JSON.stringify(body)}` : ''}`);
|
|
1225
|
+
}
|
|
1226
|
+
const failedDelivery = Array.isArray(body?.result?.deliverResults)
|
|
1227
|
+
? body.result.deliverResults.find((result) => result?.success === false)
|
|
1228
|
+
: undefined;
|
|
1229
|
+
if (failedDelivery) {
|
|
1230
|
+
throw new Error(`DingTalk card delivery failed: ${failedDelivery.errorMsg || JSON.stringify(failedDelivery)}`);
|
|
1231
|
+
}
|
|
1232
|
+
const deliveredTrackId = body?.result?.outTrackId;
|
|
1233
|
+
if (typeof deliveredTrackId !== 'string' || !deliveredTrackId) {
|
|
1234
|
+
throw new Error(`DingTalk card send succeeded without result.outTrackId: ${JSON.stringify(body)}`);
|
|
1235
|
+
}
|
|
1236
|
+
if (deliveredTrackId !== outTrackId) {
|
|
1237
|
+
throw new Error(`DingTalk card send returned an unexpected outTrackId: ${deliveredTrackId}`);
|
|
1238
|
+
}
|
|
1239
|
+
this.trackPendingCard({
|
|
1240
|
+
interaction,
|
|
1241
|
+
chatId,
|
|
1242
|
+
outTrackId,
|
|
1243
|
+
messageId: outTrackId,
|
|
1244
|
+
actionMap: this.cardActionMap(interaction),
|
|
1245
|
+
createdAt: Date.now(),
|
|
1246
|
+
});
|
|
1247
|
+
// Only supersede older cards after the replacement was delivered and
|
|
1248
|
+
// tracked. A transient create failure must not destroy the last usable
|
|
1249
|
+
// approval/menu card.
|
|
1250
|
+
await this.invalidatePendingCards(chatId, previousPendingCards);
|
|
1251
|
+
return outTrackId;
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
async processCardCallback(callback, streamMessageId) {
|
|
1255
|
+
if (!callback.outTrackId)
|
|
1256
|
+
return this.privateCardFeedback('无法识别卡片');
|
|
1257
|
+
const entry = this.cardsByTrackId.get(callback.outTrackId);
|
|
1258
|
+
if (!entry || entry.invalidated || entry.resolved)
|
|
1259
|
+
return this.privateCardFeedback('卡片已失效,请重新发起');
|
|
1260
|
+
if (entry.interaction.expiresAt && entry.interaction.expiresAt <= Date.now()) {
|
|
1261
|
+
entry.invalidated = true;
|
|
1262
|
+
entry.invalidatedReason = 'expired';
|
|
1263
|
+
entry.settledAt = Date.now();
|
|
1264
|
+
this.untrackPendingCard(entry);
|
|
1265
|
+
return this.cardResponse(entry, 'invalid', '审批已超时');
|
|
1266
|
+
}
|
|
1267
|
+
if (entry.interaction.initiatorId && callback.operatorId !== entry.interaction.initiatorId) {
|
|
1268
|
+
return this.privateCardFeedback('仅卡片发起者可操作');
|
|
1269
|
+
}
|
|
1270
|
+
if (this.cardActionsInFlight.has(callback.outTrackId))
|
|
1271
|
+
return this.privateCardFeedback('操作正在处理中');
|
|
1272
|
+
const action = callback.actionId ? entry.actionMap.get(callback.actionId) : undefined;
|
|
1273
|
+
if (!action)
|
|
1274
|
+
return this.privateCardFeedback('无法识别所选操作');
|
|
1275
|
+
this.cardActionsInFlight.add(callback.outTrackId);
|
|
1276
|
+
try {
|
|
1277
|
+
if (entry.interaction.kind.kind === 'command-card') {
|
|
1278
|
+
if (!this.messageHandler)
|
|
1279
|
+
return this.privateCardFeedback('处理器暂不可用');
|
|
1280
|
+
const route = this.routes.get(entry.chatId);
|
|
1281
|
+
await this.messageHandler({
|
|
1282
|
+
channelId: entry.chatId,
|
|
1283
|
+
content: action,
|
|
1284
|
+
chatType: route?.chatType || 'private',
|
|
1285
|
+
peerId: callback.operatorId || '',
|
|
1286
|
+
messageId: `card-trigger-${streamMessageId}`,
|
|
1287
|
+
source: 'card-trigger',
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
else {
|
|
1291
|
+
if (!this.interactionCallback)
|
|
1292
|
+
return this.privateCardFeedback('处理器暂不可用');
|
|
1293
|
+
const response = {
|
|
1294
|
+
type: 'interaction.response',
|
|
1295
|
+
id: entry.interaction.id,
|
|
1296
|
+
action,
|
|
1297
|
+
values: callback.values,
|
|
1298
|
+
operatorId: callback.operatorId,
|
|
1299
|
+
};
|
|
1300
|
+
const accepted = (await this.interactionCallback(response)) !== false;
|
|
1301
|
+
if (!accepted || entry.invalidated) {
|
|
1302
|
+
entry.invalidated = true;
|
|
1303
|
+
entry.invalidatedReason ||= 'backend_rejected';
|
|
1304
|
+
entry.settledAt = Date.now();
|
|
1305
|
+
this.untrackPendingCard(entry);
|
|
1306
|
+
return this.cardResponse(entry, 'invalid', '操作未被接受');
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
entry.resolved = true;
|
|
1310
|
+
entry.settledAt = Date.now();
|
|
1311
|
+
this.untrackPendingCard(entry);
|
|
1312
|
+
const selected = entry.interaction.kind.buttons.find(button => (entry.interaction.kind.kind === 'command-card' ? button.command : button.key) === action);
|
|
1313
|
+
return this.cardResponse(entry, 'resolved', selected?.label || '已处理');
|
|
1314
|
+
}
|
|
1315
|
+
finally {
|
|
1316
|
+
this.cardActionsInFlight.delete(callback.outTrackId);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
async handleCardCallbackMessage(msg) {
|
|
1320
|
+
const data = typeof msg.data === 'string' ? parseJsonRecord(msg.data) : parseJsonRecord(msg.data);
|
|
1321
|
+
const callback = parseDingtalkCardCallback(data);
|
|
1322
|
+
const streamMessageId = msg?.headers?.messageId || `unknown-${Date.now()}`;
|
|
1323
|
+
const work = this.processCardCallback(callback, streamMessageId);
|
|
1324
|
+
let timedOut = false;
|
|
1325
|
+
let timeoutId;
|
|
1326
|
+
const timeout = new Promise(resolve => {
|
|
1327
|
+
timeoutId = setTimeout(() => {
|
|
1328
|
+
timedOut = true;
|
|
1329
|
+
resolve(this.privateCardFeedback('操作已提交,正在处理'));
|
|
1330
|
+
}, 1_500);
|
|
1331
|
+
});
|
|
1332
|
+
try {
|
|
1333
|
+
const response = await Promise.race([work, timeout]);
|
|
1334
|
+
if (!timedOut && timeoutId)
|
|
1335
|
+
clearTimeout(timeoutId);
|
|
1336
|
+
this.acknowledgeStreamMessage(msg, response);
|
|
1337
|
+
if (timedOut) {
|
|
1338
|
+
void work.then(async (finalResponse) => {
|
|
1339
|
+
const entry = callback.outTrackId ? this.cardsByTrackId.get(callback.outTrackId) : undefined;
|
|
1340
|
+
const cardData = finalResponse?.cardData;
|
|
1341
|
+
if (entry && cardData) {
|
|
1342
|
+
const state = entry.invalidated ? 'invalid' : 'resolved';
|
|
1343
|
+
const status = cardData?.cardParamMap?.status || '已处理';
|
|
1344
|
+
await this.updateCard(entry, state, status).catch(error => logger.warn('[DingTalk] Delayed card update failed:', error));
|
|
1345
|
+
}
|
|
1346
|
+
}).catch(error => logger.error('[DingTalk] Delayed card callback failed:', error));
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
catch (error) {
|
|
1350
|
+
if (timeoutId)
|
|
1351
|
+
clearTimeout(timeoutId);
|
|
1352
|
+
logger.error('[DingTalk] Card callback failed:', error);
|
|
1353
|
+
this.acknowledgeStreamMessage(msg, this.privateCardFeedback('操作失败,请重试'));
|
|
554
1354
|
}
|
|
555
1355
|
}
|
|
556
1356
|
}
|
|
@@ -565,17 +1365,25 @@ export class DingtalkChannelPlugin {
|
|
|
565
1365
|
return null;
|
|
566
1366
|
if (!isValidCredential(inst.clientId) || !isValidCredential(inst.clientSecret))
|
|
567
1367
|
return null;
|
|
1368
|
+
const stateKey = createHash('sha256').update(`${ctx.agentName}:${inst.name}`).digest('hex').slice(0, 16);
|
|
1369
|
+
const dataDir = resolvePaths().dataDir;
|
|
1370
|
+
if (inst.cardCallbackRouteKey) {
|
|
1371
|
+
logger.warn(`[DingTalk] cardCallbackRouteKey is ignored for ${inst.name}; interactive cards use Stream callbacks`);
|
|
1372
|
+
}
|
|
568
1373
|
const channel = new DingtalkChannel({
|
|
569
1374
|
clientId: inst.clientId,
|
|
570
1375
|
clientSecret: inst.clientSecret,
|
|
571
1376
|
requireMention: inst.requireMention,
|
|
572
1377
|
freeResponseChats: inst.freeResponseChats,
|
|
1378
|
+
cardTemplateId: inst.cardTemplateId,
|
|
1379
|
+
seenMsgFile: path.join(dataDir, `dingtalk-seen-${stateKey}.jsonl`),
|
|
1380
|
+
routeFile: path.join(dataDir, `dingtalk-routes-${stateKey}.json`),
|
|
573
1381
|
}, ctx.agentName, inst.name);
|
|
574
1382
|
const mode = resolveShowActivities(inst);
|
|
575
1383
|
const adapter = {
|
|
576
1384
|
channelName: inst.name,
|
|
577
1385
|
channelKey: inst.name,
|
|
578
|
-
capabilities: { file: true, image: true, interaction:
|
|
1386
|
+
capabilities: { file: true, image: true, interaction: !!inst.cardTemplateId, markdown: true, thought: false, status: true, thread: false, authenticatedApproval: true },
|
|
579
1387
|
send: async (envelope, payload) => {
|
|
580
1388
|
const channelId = envelope.channelId;
|
|
581
1389
|
switch (payload.kind) {
|
|
@@ -585,35 +1393,55 @@ export class DingtalkChannelPlugin {
|
|
|
585
1393
|
case 'system.notice':
|
|
586
1394
|
case 'system.error':
|
|
587
1395
|
case 'result.error':
|
|
588
|
-
await channel.sendMessage(channelId, payload.text);
|
|
589
|
-
|
|
590
|
-
case 'result.
|
|
591
|
-
await channel.sendFile(channelId, payload.filePath);
|
|
592
|
-
return;
|
|
593
|
-
case 'result.image':
|
|
594
|
-
await channel.sendImage(channelId, payload.data);
|
|
595
|
-
return;
|
|
1396
|
+
return sentReceipt(envelope, await channel.sendMessage(channelId, payload.text));
|
|
1397
|
+
case 'result.file': return sentReceipt(envelope, await channel.sendFile(channelId, payload.filePath));
|
|
1398
|
+
case 'result.image': return sentReceipt(envelope, await channel.sendImage(channelId, payload.data));
|
|
596
1399
|
case 'activity.batch': {
|
|
597
1400
|
const filtered = payload.items.filter((i) => !(i.kind === 'tool_result' && i.ok));
|
|
598
1401
|
const text = formatItemsAsText(filtered);
|
|
599
1402
|
if (text)
|
|
600
|
-
await channel.sendMessage(channelId, text);
|
|
601
|
-
return;
|
|
1403
|
+
return sentReceipt(envelope, await channel.sendMessage(channelId, text));
|
|
1404
|
+
return suppressedReceipt(envelope, 'empty_activity');
|
|
602
1405
|
}
|
|
1406
|
+
case 'status.started':
|
|
1407
|
+
case 'status.queued':
|
|
1408
|
+
case 'status.progress':
|
|
1409
|
+
return suppressedReceipt(envelope, 'reaction_lifecycle');
|
|
1410
|
+
case 'status.completed':
|
|
1411
|
+
await channel.completeAckReaction(envelope.taskId, true);
|
|
1412
|
+
return suppressedReceipt(envelope, 'reaction_lifecycle');
|
|
1413
|
+
case 'status.interrupted':
|
|
1414
|
+
case 'status.timeout':
|
|
1415
|
+
await channel.completeAckReaction(envelope.taskId, false);
|
|
1416
|
+
return suppressedReceipt(envelope, 'reaction_lifecycle');
|
|
603
1417
|
case 'status.requires_action':
|
|
604
|
-
await channel.sendMessage(channelId, '等待 owner 审批');
|
|
605
|
-
return;
|
|
1418
|
+
return sentReceipt(envelope, await channel.sendMessage(channelId, '等待 owner 审批'));
|
|
606
1419
|
case 'status.error':
|
|
1420
|
+
await channel.completeAckReaction(envelope.taskId, false);
|
|
607
1421
|
if (payload.metadata?.message)
|
|
608
|
-
await channel.sendMessage(channelId, payload.metadata.message);
|
|
609
|
-
return;
|
|
610
|
-
case 'interaction':
|
|
1422
|
+
return sentReceipt(envelope, await channel.sendMessage(channelId, payload.metadata.message));
|
|
1423
|
+
return suppressedReceipt(envelope, 'reaction_lifecycle');
|
|
1424
|
+
case 'interaction': {
|
|
1425
|
+
try {
|
|
1426
|
+
const messageId = await channel.sendInteraction(channelId, payload.interaction);
|
|
1427
|
+
if (messageId)
|
|
1428
|
+
return sentReceipt(envelope, [messageId]);
|
|
1429
|
+
}
|
|
1430
|
+
catch (error) {
|
|
1431
|
+
logger.warn(`[DingTalk] Interactive card delivery failed, using text fallback: ${error instanceof Error ? error.message : String(error)}`);
|
|
1432
|
+
}
|
|
611
1433
|
if (payload.fallbackText)
|
|
612
|
-
await channel.sendMessage(channelId, payload.fallbackText);
|
|
613
|
-
|
|
614
|
-
|
|
1434
|
+
return sentReceipt(envelope, await channel.sendMessage(channelId, payload.fallbackText));
|
|
1435
|
+
throw new Error('DingTalk interaction delivery failed and no fallback text was provided');
|
|
1436
|
+
}
|
|
1437
|
+
default: return suppressedReceipt(envelope, 'unhandled_payload');
|
|
615
1438
|
}
|
|
616
1439
|
},
|
|
1440
|
+
acknowledge: (messageId) => channel.acknowledgeMessage(messageId),
|
|
1441
|
+
promoteAck: (messageId, context) => (channel.promoteAckReaction(messageId, context?.taskId, context?.messageIds)),
|
|
1442
|
+
onInteraction: (callback) => channel.onInteraction(callback),
|
|
1443
|
+
onInteractionInvalidated: (callback) => channel.onInteractionInvalidated(callback),
|
|
1444
|
+
invalidateInteraction: (interactionId, reason) => channel.invalidateInteraction(interactionId, reason),
|
|
617
1445
|
};
|
|
618
1446
|
const policy = {
|
|
619
1447
|
canSwitchProject: (_, identity) => identity === 'owner' || identity === 'admin',
|
|
@@ -641,8 +1469,10 @@ export class DingtalkChannelPlugin {
|
|
|
641
1469
|
selfAID: ctx.agentName, content: event.content, images: event.images,
|
|
642
1470
|
chatType: event.chatType || 'private', peerId: event.peerId || '',
|
|
643
1471
|
peerName: event.peerName, messageId: event.messageId,
|
|
1472
|
+
mentions: event.mentions, isMentioned: event.isMentioned,
|
|
1473
|
+
topicName: event.topicName, source: event.source,
|
|
644
1474
|
});
|
|
645
|
-
}), (channelId, text) => channel.sendMessage(channelId, text), adapter, channelType);
|
|
1475
|
+
}), async (channelId, text) => { await channel.sendMessage(channelId, text); }, adapter, channelType);
|
|
646
1476
|
},
|
|
647
1477
|
};
|
|
648
1478
|
}
|