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