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
package/dist/channels/wecom.js
CHANGED
|
@@ -1,21 +1,500 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
2
5
|
import { logger } from '../utils/logger.js';
|
|
3
6
|
import { requireOptional } from '../utils/npm-ops.js';
|
|
4
7
|
import { middleOutputModePolicy, resolveShowActivities, showActivitiesPolicy } from '../core/channel-loader.js';
|
|
5
8
|
import { formatItemsAsText } from '../core/message/items-formatter.js';
|
|
9
|
+
import { createSendFileMarkerPattern } from '../core/message/file-markers.js';
|
|
6
10
|
import { initWelcomeManager, sendWelcomeIfNeeded } from '../utils/welcome.js';
|
|
11
|
+
import { bufferToInboundImage, sanitizeFileName, saveToUploads, validateUrl } from '../utils/media-cache.js';
|
|
12
|
+
import { agentDataDir } from '../paths.js';
|
|
13
|
+
import { atomicReadJson, atomicWriteJson } from '../utils/atomic-write.js';
|
|
14
|
+
import { ContactBindCodeRegistry, handleExplicitContactBindCommand, } from '../config/contact-bind-code.js';
|
|
15
|
+
function wecomErrorCode(error) {
|
|
16
|
+
if (!error || typeof error !== 'object')
|
|
17
|
+
return undefined;
|
|
18
|
+
const direct = error.errcode;
|
|
19
|
+
if (typeof direct === 'number')
|
|
20
|
+
return direct;
|
|
21
|
+
const responseCode = error.response?.data?.errcode;
|
|
22
|
+
return typeof responseCode === 'number' ? responseCode : undefined;
|
|
23
|
+
}
|
|
24
|
+
function isWecomAuthError(error) {
|
|
25
|
+
return error.code === 'WS_AUTH_FAILURE_EXHAUSTED'
|
|
26
|
+
|| /^Authentication failed:/i.test(error.message);
|
|
27
|
+
}
|
|
28
|
+
function sanitizeWecomSdkLog(message) {
|
|
29
|
+
return message
|
|
30
|
+
.replace(/("(?:aeskey|secret)"\s*:\s*")[^"]*(")/gi, '$1<redacted>$2')
|
|
31
|
+
.replace(/("url"\s*:\s*")[^"]*(")/gi, '$1<redacted>$2')
|
|
32
|
+
.replace(/(url=)https?:\/\/\S+/gi, '$1<redacted>');
|
|
33
|
+
}
|
|
34
|
+
function validateWecomMediaUrl(url) {
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = new URL(url);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new Error('WeCom media URL is invalid');
|
|
41
|
+
}
|
|
42
|
+
if (parsed.protocol !== 'https:')
|
|
43
|
+
throw new Error(`WeCom media URL must use HTTPS: ${parsed.protocol}`);
|
|
44
|
+
if (parsed.username || parsed.password)
|
|
45
|
+
throw new Error('WeCom media URL must not contain credentials');
|
|
46
|
+
const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
47
|
+
if (host === 'localhost'
|
|
48
|
+
|| host.endsWith('.localhost')
|
|
49
|
+
|| host.endsWith('.local')
|
|
50
|
+
|| host === '::1'
|
|
51
|
+
|| /^(?:fc|fd|fe8|fe9|fea|feb)/i.test(host)) {
|
|
52
|
+
throw new Error(`WeCom media URL rejected: local host ${host}`);
|
|
53
|
+
}
|
|
54
|
+
const validation = validateUrl(url, { allowedHosts: new Set() });
|
|
55
|
+
if (!validation.ok)
|
|
56
|
+
throw new Error(`WeCom media URL rejected: ${validation.reason}`);
|
|
57
|
+
}
|
|
58
|
+
function truncateUtf8Bytes(value, maxBytes) {
|
|
59
|
+
const input = Buffer.from(value, 'utf8');
|
|
60
|
+
if (input.length <= maxBytes)
|
|
61
|
+
return value;
|
|
62
|
+
let end = maxBytes;
|
|
63
|
+
while (end > 0 && (input[end] & 0xc0) === 0x80)
|
|
64
|
+
end--;
|
|
65
|
+
return input.subarray(0, end).toString('utf8');
|
|
66
|
+
}
|
|
67
|
+
function splitUtf8Bytes(value, maxBytes) {
|
|
68
|
+
if (!value)
|
|
69
|
+
return [];
|
|
70
|
+
const chunks = [];
|
|
71
|
+
let remaining = value;
|
|
72
|
+
while (Buffer.byteLength(remaining, 'utf8') > maxBytes) {
|
|
73
|
+
let chunk = truncateUtf8Bytes(remaining, maxBytes);
|
|
74
|
+
const paragraphBreak = chunk.lastIndexOf('\n\n');
|
|
75
|
+
const lineBreak = chunk.lastIndexOf('\n');
|
|
76
|
+
const splitAt = paragraphBreak > Math.floor(chunk.length / 2)
|
|
77
|
+
? paragraphBreak
|
|
78
|
+
: lineBreak > Math.floor(chunk.length / 2)
|
|
79
|
+
? lineBreak
|
|
80
|
+
: chunk.length;
|
|
81
|
+
chunk = chunk.slice(0, splitAt);
|
|
82
|
+
if (!chunk)
|
|
83
|
+
chunk = truncateUtf8Bytes(remaining, maxBytes);
|
|
84
|
+
chunks.push(chunk);
|
|
85
|
+
remaining = remaining.slice(chunk.length);
|
|
86
|
+
}
|
|
87
|
+
if (remaining)
|
|
88
|
+
chunks.push(remaining);
|
|
89
|
+
return chunks;
|
|
90
|
+
}
|
|
91
|
+
const wecomContactBinds = new ContactBindCodeRegistry({
|
|
92
|
+
channelType: 'wecom',
|
|
93
|
+
displayName: '企微',
|
|
94
|
+
initCommand: 'ec init wecom',
|
|
95
|
+
});
|
|
96
|
+
export function registerPendingWecomContactBind(req) {
|
|
97
|
+
return wecomContactBinds.register(req);
|
|
98
|
+
}
|
|
99
|
+
export function handlePendingWecomContactBindMessage(ctx) {
|
|
100
|
+
return handleExplicitContactBindCommand(wecomContactBinds, ctx);
|
|
101
|
+
}
|
|
102
|
+
export function getPendingWecomContactBind(selfAid, channelName, primaryId) {
|
|
103
|
+
return wecomContactBinds.get(selfAid, channelName, primaryId);
|
|
104
|
+
}
|
|
105
|
+
export function clearPendingWecomContactBinds() {
|
|
106
|
+
wecomContactBinds.clear();
|
|
107
|
+
}
|
|
108
|
+
// ── Persistent channel state ──────────────────────────────────────────────────
|
|
109
|
+
const STORE_VERSION = 1;
|
|
110
|
+
const DEFAULT_DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
|
|
111
|
+
const DEFAULT_CARD_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
112
|
+
function writePrivateJson(filePath, value) {
|
|
113
|
+
atomicWriteJson(filePath, value);
|
|
114
|
+
try {
|
|
115
|
+
fs.chmodSync(filePath, 0o600);
|
|
116
|
+
}
|
|
117
|
+
catch { /* best effort on non-POSIX filesystems */ }
|
|
118
|
+
}
|
|
119
|
+
export class PersistentWecomDeduper {
|
|
120
|
+
filePath;
|
|
121
|
+
channelKey;
|
|
122
|
+
entries = new Map();
|
|
123
|
+
ttlMs;
|
|
124
|
+
maxEntries;
|
|
125
|
+
constructor(filePath, channelKey, opts) {
|
|
126
|
+
this.filePath = filePath;
|
|
127
|
+
this.channelKey = channelKey;
|
|
128
|
+
this.ttlMs = opts?.ttlMs ?? DEFAULT_DEDUP_TTL_MS;
|
|
129
|
+
this.maxEntries = opts?.maxEntries ?? 20_000;
|
|
130
|
+
this.load();
|
|
131
|
+
}
|
|
132
|
+
checkAndMark(messageId, now = Date.now()) {
|
|
133
|
+
this.evict(now);
|
|
134
|
+
const seenAt = this.entries.get(messageId);
|
|
135
|
+
if (seenAt !== undefined && now - seenAt <= this.ttlMs)
|
|
136
|
+
return true;
|
|
137
|
+
this.entries.set(messageId, now);
|
|
138
|
+
this.trimToLimit();
|
|
139
|
+
this.flush();
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
get size() {
|
|
143
|
+
return this.entries.size;
|
|
144
|
+
}
|
|
145
|
+
load() {
|
|
146
|
+
if (!this.filePath)
|
|
147
|
+
return;
|
|
148
|
+
try {
|
|
149
|
+
const stored = atomicReadJson(this.filePath);
|
|
150
|
+
if (!stored || stored.version !== STORE_VERSION || stored.channelKey !== this.channelKey)
|
|
151
|
+
return;
|
|
152
|
+
for (const [id, timestamp] of Object.entries(stored.entries ?? {})) {
|
|
153
|
+
if (id && Number.isFinite(timestamp))
|
|
154
|
+
this.entries.set(id, timestamp);
|
|
155
|
+
}
|
|
156
|
+
const before = this.entries.size;
|
|
157
|
+
this.evict(Date.now());
|
|
158
|
+
this.trimToLimit();
|
|
159
|
+
if (this.entries.size !== before)
|
|
160
|
+
this.flush();
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
logger.warn(`[WeCom] Ignoring unreadable dedup store for ${this.channelKey}: ${String(error)}`);
|
|
164
|
+
this.entries.clear();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
evict(now) {
|
|
168
|
+
for (const [id, timestamp] of this.entries) {
|
|
169
|
+
if (now - timestamp > this.ttlMs)
|
|
170
|
+
this.entries.delete(id);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
trimToLimit() {
|
|
174
|
+
if (this.entries.size <= this.maxEntries)
|
|
175
|
+
return;
|
|
176
|
+
const oldest = [...this.entries.entries()].sort((a, b) => a[1] - b[1]);
|
|
177
|
+
for (let i = 0; i < oldest.length - this.maxEntries; i++) {
|
|
178
|
+
this.entries.delete(oldest[i][0]);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
flush() {
|
|
182
|
+
writePrivateJson(this.filePath, {
|
|
183
|
+
version: STORE_VERSION,
|
|
184
|
+
channelKey: this.channelKey,
|
|
185
|
+
entries: Object.fromEntries(this.entries),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export class PersistentWecomCardStore {
|
|
190
|
+
filePath;
|
|
191
|
+
channelKey;
|
|
192
|
+
records = new Map();
|
|
193
|
+
constructor(filePath, channelKey) {
|
|
194
|
+
this.filePath = filePath;
|
|
195
|
+
this.channelKey = channelKey;
|
|
196
|
+
if (filePath)
|
|
197
|
+
this.load();
|
|
198
|
+
}
|
|
199
|
+
set(record) {
|
|
200
|
+
this.records.set(record.taskId, record);
|
|
201
|
+
this.cleanup();
|
|
202
|
+
this.flush();
|
|
203
|
+
}
|
|
204
|
+
get(taskId) {
|
|
205
|
+
this.cleanup();
|
|
206
|
+
return this.records.get(taskId);
|
|
207
|
+
}
|
|
208
|
+
findByInteractionId(interactionId) {
|
|
209
|
+
this.cleanup();
|
|
210
|
+
return [...this.records.values()].find(record => record.interaction.id === interactionId);
|
|
211
|
+
}
|
|
212
|
+
listPendingByChat(chatId) {
|
|
213
|
+
this.cleanup();
|
|
214
|
+
return [...this.records.values()].filter(record => (record.chatId === chatId
|
|
215
|
+
&& record.status === 'pending'
|
|
216
|
+
&& (!record.expiresAt || record.expiresAt >= Date.now())));
|
|
217
|
+
}
|
|
218
|
+
resolve(taskId, action) {
|
|
219
|
+
const record = this.records.get(taskId);
|
|
220
|
+
if (!record)
|
|
221
|
+
return undefined;
|
|
222
|
+
record.status = 'resolved';
|
|
223
|
+
record.resolution = action;
|
|
224
|
+
record.updatedAt = Date.now();
|
|
225
|
+
this.flush();
|
|
226
|
+
return record;
|
|
227
|
+
}
|
|
228
|
+
invalidateByInteractionId(interactionId, reason) {
|
|
229
|
+
const record = this.findByInteractionId(interactionId);
|
|
230
|
+
if (!record || record.status === 'resolved')
|
|
231
|
+
return record;
|
|
232
|
+
record.status = 'invalidated';
|
|
233
|
+
record.invalidationReason = reason;
|
|
234
|
+
record.updatedAt = Date.now();
|
|
235
|
+
this.flush();
|
|
236
|
+
return record;
|
|
237
|
+
}
|
|
238
|
+
load() {
|
|
239
|
+
if (!this.filePath)
|
|
240
|
+
return;
|
|
241
|
+
try {
|
|
242
|
+
const stored = atomicReadJson(this.filePath);
|
|
243
|
+
if (!stored || stored.version !== STORE_VERSION || stored.channelKey !== this.channelKey)
|
|
244
|
+
return;
|
|
245
|
+
for (const record of stored.records ?? []) {
|
|
246
|
+
if (record?.taskId && record?.interaction?.id)
|
|
247
|
+
this.records.set(record.taskId, record);
|
|
248
|
+
}
|
|
249
|
+
this.cleanup(true);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
logger.warn(`[WeCom] Ignoring unreadable card store for ${this.channelKey}: ${String(error)}`);
|
|
253
|
+
this.records.clear();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
cleanup(flushWhenChanged = false) {
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
let changed = false;
|
|
259
|
+
for (const [taskId, record] of this.records) {
|
|
260
|
+
const terminalExpiry = record.updatedAt + DEFAULT_CARD_RETENTION_MS;
|
|
261
|
+
const pendingExpiry = (record.expiresAt ?? record.createdAt + 7 * DEFAULT_CARD_RETENTION_MS)
|
|
262
|
+
+ DEFAULT_CARD_RETENTION_MS;
|
|
263
|
+
const expiresAt = record.status === 'pending' ? pendingExpiry : terminalExpiry;
|
|
264
|
+
if (now > expiresAt) {
|
|
265
|
+
this.records.delete(taskId);
|
|
266
|
+
changed = true;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (changed && flushWhenChanged)
|
|
270
|
+
this.flush();
|
|
271
|
+
}
|
|
272
|
+
flush() {
|
|
273
|
+
if (!this.filePath)
|
|
274
|
+
return;
|
|
275
|
+
writePrivateJson(this.filePath, {
|
|
276
|
+
version: STORE_VERSION,
|
|
277
|
+
channelKey: this.channelKey,
|
|
278
|
+
records: [...this.records.values()],
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
export function wecomChannelDataPaths(agentAid, channelKey) {
|
|
283
|
+
const safeKey = encodeURIComponent(channelKey);
|
|
284
|
+
const dir = path.join(agentDataDir(agentAid), 'channels', safeKey);
|
|
285
|
+
return {
|
|
286
|
+
dedupFile: path.join(dir, 'wecom-message-dedup.json'),
|
|
287
|
+
cardFile: path.join(dir, 'wecom-cards.json'),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
export function createWecomCardTaskId(channelKey, interactionId) {
|
|
291
|
+
const hash = crypto.createHash('sha256').update(`${channelKey}\0${interactionId}`).digest('hex').slice(0, 32);
|
|
292
|
+
return `ec_${hash}`;
|
|
293
|
+
}
|
|
294
|
+
// ── Template cards ────────────────────────────────────────────────────────────
|
|
295
|
+
function truncate(value, max) {
|
|
296
|
+
if (!value)
|
|
297
|
+
return undefined;
|
|
298
|
+
const chars = [...value.trim()];
|
|
299
|
+
return chars.length <= max ? value.trim() : `${chars.slice(0, Math.max(1, max - 1)).join('')}...`;
|
|
300
|
+
}
|
|
301
|
+
function buttonStyle(style) {
|
|
302
|
+
if (style === 'primary')
|
|
303
|
+
return 2;
|
|
304
|
+
if (style === 'danger')
|
|
305
|
+
return 3;
|
|
306
|
+
return 1;
|
|
307
|
+
}
|
|
308
|
+
export function buildWecomInteractionCard(interaction, taskId) {
|
|
309
|
+
const bodyParts = [];
|
|
310
|
+
if (interaction.kind.body)
|
|
311
|
+
bodyParts.push(interaction.kind.body);
|
|
312
|
+
if (interaction.kind.kind === 'action' && interaction.kind.checkers?.length) {
|
|
313
|
+
bodyParts.push(interaction.kind.checkers.map((item, index) => `${index + 1}. ${item.label}`).join('\n'));
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
card_type: 'button_interaction',
|
|
317
|
+
task_id: taskId,
|
|
318
|
+
main_title: {
|
|
319
|
+
title: truncate(interaction.kind.title, 26),
|
|
320
|
+
desc: truncate(bodyParts.join('\n\n'), 120),
|
|
321
|
+
},
|
|
322
|
+
button_list: interaction.kind.buttons
|
|
323
|
+
.map((button, index) => ({ button, index }))
|
|
324
|
+
.filter(({ button }) => !('disabled' in button) || !button.disabled)
|
|
325
|
+
.slice(0, 6)
|
|
326
|
+
.map(({ button, index }) => ({
|
|
327
|
+
text: truncate(button.label, 10) || `选项 ${index + 1}`,
|
|
328
|
+
style: buttonStyle(button.style),
|
|
329
|
+
key: interaction.kind.kind === 'command-card' ? `cmd_${index}` : `act_${index}`,
|
|
330
|
+
})),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
export function parseWecomCardResponse(record, event, operatorId) {
|
|
334
|
+
const eventKey = String(event.event_key ?? '').trim();
|
|
335
|
+
const match = /^(cmd|act)_(\d+)$/.exec(eventKey);
|
|
336
|
+
if (!match)
|
|
337
|
+
return null;
|
|
338
|
+
const index = Number(match[2]);
|
|
339
|
+
if (record.interaction.kind.kind === 'command-card') {
|
|
340
|
+
const button = record.interaction.kind.buttons[index];
|
|
341
|
+
if (!button || button.disabled)
|
|
342
|
+
return null;
|
|
343
|
+
return { command: button.command };
|
|
344
|
+
}
|
|
345
|
+
const button = record.interaction.kind.buttons[index];
|
|
346
|
+
if (!button)
|
|
347
|
+
return null;
|
|
348
|
+
const values = {};
|
|
349
|
+
const selectedItems = event.selected_items?.selected_item;
|
|
350
|
+
if (Array.isArray(selectedItems)) {
|
|
351
|
+
for (const selected of selectedItems) {
|
|
352
|
+
const questionKey = String(selected?.question_key ?? '').trim();
|
|
353
|
+
const optionIds = selected?.option_ids?.option_id;
|
|
354
|
+
if (questionKey && Array.isArray(optionIds))
|
|
355
|
+
values[questionKey] = optionIds.filter(Boolean);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
response: {
|
|
360
|
+
type: 'interaction.response',
|
|
361
|
+
id: record.interaction.id,
|
|
362
|
+
action: button.key,
|
|
363
|
+
values: Object.keys(values).length > 0 ? values : undefined,
|
|
364
|
+
operatorId,
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
export function buildWecomTerminalCard(record, status, detail) {
|
|
369
|
+
const statusText = status === 'resolved'
|
|
370
|
+
? `已处理${detail ? `:${detail}` : ''}`
|
|
371
|
+
: status === 'rejected'
|
|
372
|
+
? '仅卡片发起者可操作'
|
|
373
|
+
: status === 'unavailable'
|
|
374
|
+
? '处理服务暂不可用,请重新发起'
|
|
375
|
+
: `卡片已失效${detail ? `:${detail}` : ''}`;
|
|
376
|
+
return {
|
|
377
|
+
card_type: 'text_notice',
|
|
378
|
+
task_id: record.taskId,
|
|
379
|
+
main_title: {
|
|
380
|
+
title: truncate(record.interaction.kind.title, 26),
|
|
381
|
+
desc: truncate(statusText, 120),
|
|
382
|
+
},
|
|
383
|
+
sub_title_text: truncate(record.interaction.kind.body, 120),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
export function buildWecomUnknownCard(taskId) {
|
|
387
|
+
return {
|
|
388
|
+
card_type: 'text_notice',
|
|
389
|
+
task_id: taskId,
|
|
390
|
+
main_title: {
|
|
391
|
+
title: '卡片已失效',
|
|
392
|
+
desc: '本地状态不存在,请重新发起操作',
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
// ── QR onboarding ─────────────────────────────────────────────────────────────
|
|
397
|
+
const WECOM_QR_GENERATE_URL = 'https://work.weixin.qq.com/ai/qc/generate';
|
|
398
|
+
const WECOM_QR_QUERY_URL = 'https://work.weixin.qq.com/ai/qc/query_result';
|
|
399
|
+
const WECOM_QR_PAGE_URL = 'https://work.weixin.qq.com/ai/qc/gen';
|
|
400
|
+
function platformCode() {
|
|
401
|
+
if (os.platform() === 'darwin')
|
|
402
|
+
return 1;
|
|
403
|
+
if (os.platform() === 'win32')
|
|
404
|
+
return 2;
|
|
405
|
+
if (os.platform() === 'linux')
|
|
406
|
+
return 3;
|
|
407
|
+
return 0;
|
|
408
|
+
}
|
|
409
|
+
async function fetchJson(url, fetchImpl, signal) {
|
|
410
|
+
const timeoutSignal = AbortSignal.timeout(15_000);
|
|
411
|
+
const response = await fetchImpl(url, {
|
|
412
|
+
headers: { Accept: 'application/json' },
|
|
413
|
+
signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
|
|
414
|
+
});
|
|
415
|
+
if (!response.ok)
|
|
416
|
+
throw new Error(`企微扫码服务请求失败: HTTP ${response.status}`);
|
|
417
|
+
return response.json();
|
|
418
|
+
}
|
|
419
|
+
export async function createWecomQrSession(fetchImpl = fetch) {
|
|
420
|
+
const url = new URL(WECOM_QR_GENERATE_URL);
|
|
421
|
+
url.searchParams.set('source', 'wecom-cli');
|
|
422
|
+
url.searchParams.set('plat', String(platformCode()));
|
|
423
|
+
const payload = await fetchJson(url, fetchImpl);
|
|
424
|
+
const scode = String(payload?.data?.scode ?? '').trim();
|
|
425
|
+
const authUrl = String(payload?.data?.auth_url ?? '').trim();
|
|
426
|
+
if (!scode || !authUrl)
|
|
427
|
+
throw new Error('企微扫码服务未返回 scode 或授权地址');
|
|
428
|
+
const browserUrl = new URL(WECOM_QR_PAGE_URL);
|
|
429
|
+
browserUrl.searchParams.set('source', 'wecom-cli');
|
|
430
|
+
browserUrl.searchParams.set('scode', scode);
|
|
431
|
+
return { scode, authUrl, browserUrl: browserUrl.toString() };
|
|
432
|
+
}
|
|
433
|
+
export async function pollWecomQrCredentials(scode, opts) {
|
|
434
|
+
const fetchImpl = opts?.fetchImpl ?? fetch;
|
|
435
|
+
const timeoutMs = opts?.timeoutMs ?? 5 * 60 * 1000;
|
|
436
|
+
const intervalMs = opts?.intervalMs ?? 3_000;
|
|
437
|
+
const deadline = Date.now() + timeoutMs;
|
|
438
|
+
const url = new URL(WECOM_QR_QUERY_URL);
|
|
439
|
+
url.searchParams.set('scode', scode);
|
|
440
|
+
let previousStatus = '';
|
|
441
|
+
while (Date.now() < deadline) {
|
|
442
|
+
if (opts?.signal?.aborted)
|
|
443
|
+
throw new Error('企微扫码已取消');
|
|
444
|
+
const payload = await fetchJson(url, fetchImpl, opts?.signal);
|
|
445
|
+
const status = String(payload?.data?.status ?? 'waiting');
|
|
446
|
+
if (status !== previousStatus) {
|
|
447
|
+
opts?.onStatus?.(status);
|
|
448
|
+
previousStatus = status;
|
|
449
|
+
}
|
|
450
|
+
if (status === 'success') {
|
|
451
|
+
const botId = String(payload?.data?.bot_info?.botid ?? '').trim();
|
|
452
|
+
const secret = String(payload?.data?.bot_info?.secret ?? '').trim();
|
|
453
|
+
if (!botId || !secret)
|
|
454
|
+
throw new Error('扫码成功,但企微未返回 Bot ID 或 Secret');
|
|
455
|
+
return { botId, secret };
|
|
456
|
+
}
|
|
457
|
+
if (status === 'expired' || status === 'cancelled' || status === 'rejected') {
|
|
458
|
+
throw new Error(`企微扫码未完成: ${status}`);
|
|
459
|
+
}
|
|
460
|
+
await new Promise((resolve, reject) => {
|
|
461
|
+
const finish = () => {
|
|
462
|
+
opts?.signal?.removeEventListener('abort', onAbort);
|
|
463
|
+
resolve();
|
|
464
|
+
};
|
|
465
|
+
const timer = setTimeout(finish, intervalMs);
|
|
466
|
+
const onAbort = () => {
|
|
467
|
+
clearTimeout(timer);
|
|
468
|
+
opts?.signal?.removeEventListener('abort', onAbort);
|
|
469
|
+
reject(new Error('企微扫码已取消'));
|
|
470
|
+
};
|
|
471
|
+
opts?.signal?.addEventListener('abort', onAbort, { once: true });
|
|
472
|
+
if (opts?.signal?.aborted)
|
|
473
|
+
onAbort();
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
throw new Error('企微扫码超时,请重新生成二维码');
|
|
477
|
+
}
|
|
7
478
|
// ── WecomChannel ───────────────────────────────────────────────────────────────
|
|
8
479
|
export class WecomChannel {
|
|
9
480
|
agentAid;
|
|
10
481
|
channelName;
|
|
11
482
|
config;
|
|
12
483
|
client = null;
|
|
13
|
-
|
|
484
|
+
connectionState = 'idle';
|
|
485
|
+
lastConnectionError;
|
|
14
486
|
messageHandler = null;
|
|
15
487
|
seenMessages = new Map();
|
|
488
|
+
persistentDeduper;
|
|
489
|
+
cardStore;
|
|
16
490
|
cleanupInterval = null;
|
|
17
491
|
projectPathProvider = null;
|
|
18
|
-
|
|
492
|
+
interactionCallback;
|
|
493
|
+
interactionInvalidationCallback;
|
|
494
|
+
channelKey;
|
|
495
|
+
cardCallbacksInFlight = new Set();
|
|
496
|
+
interactionSendTails = new Map();
|
|
497
|
+
connectPromise;
|
|
19
498
|
activeStreams = new Map();
|
|
20
499
|
// Welcome message manager
|
|
21
500
|
welcomeManager;
|
|
@@ -23,6 +502,13 @@ export class WecomChannel {
|
|
|
23
502
|
this.agentAid = agentAid;
|
|
24
503
|
this.channelName = channelName;
|
|
25
504
|
this.config = config;
|
|
505
|
+
this.channelKey = channelName || `wecom#${agentAid || 'standalone'}#main`;
|
|
506
|
+
this.cardStore = new PersistentWecomCardStore(undefined, this.channelKey);
|
|
507
|
+
if (agentAid && channelName) {
|
|
508
|
+
const paths = wecomChannelDataPaths(agentAid, channelName);
|
|
509
|
+
this.persistentDeduper = new PersistentWecomDeduper(paths.dedupFile, channelName);
|
|
510
|
+
this.cardStore = new PersistentWecomCardStore(paths.cardFile, channelName);
|
|
511
|
+
}
|
|
26
512
|
// 初始化 welcomeManager(使用共享帮助函数)
|
|
27
513
|
if (agentAid && channelName) {
|
|
28
514
|
this.welcomeManager = initWelcomeManager('wecom', agentAid, channelName);
|
|
@@ -30,51 +516,143 @@ export class WecomChannel {
|
|
|
30
516
|
}
|
|
31
517
|
// ── Public helpers (testable) ─────────────────────────────────────────────
|
|
32
518
|
isDuplicate(msgId) {
|
|
519
|
+
if (this.persistentDeduper)
|
|
520
|
+
return this.persistentDeduper.checkAndMark(msgId);
|
|
33
521
|
if (this.seenMessages.has(msgId))
|
|
34
522
|
return true;
|
|
35
523
|
this.seenMessages.set(msgId, Date.now());
|
|
36
524
|
return false;
|
|
37
525
|
}
|
|
38
526
|
resolveChatId(chattype, chatid, userid) {
|
|
39
|
-
return chattype === 'group'
|
|
527
|
+
return chattype === 'group' ? (chatid || '') : userid;
|
|
40
528
|
}
|
|
41
529
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
42
530
|
async connect() {
|
|
531
|
+
if (this.connectionState === 'authenticated' && this.client)
|
|
532
|
+
return;
|
|
533
|
+
if (this.connectPromise)
|
|
534
|
+
return this.connectPromise;
|
|
535
|
+
if (this.client)
|
|
536
|
+
return this.waitForAuthentication(this.client, 20_000);
|
|
537
|
+
const pending = this.connectOnce();
|
|
538
|
+
this.connectPromise = pending;
|
|
539
|
+
try {
|
|
540
|
+
await pending;
|
|
541
|
+
}
|
|
542
|
+
finally {
|
|
543
|
+
if (this.connectPromise === pending)
|
|
544
|
+
this.connectPromise = undefined;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async connectOnce() {
|
|
43
548
|
const { botId, secret } = this.config;
|
|
44
549
|
if (!botId || !secret) {
|
|
45
550
|
throw new Error('WeCom botId/secret not configured');
|
|
46
551
|
}
|
|
47
|
-
const
|
|
48
|
-
this.
|
|
552
|
+
const sdk = await requireOptional('@wecom/aibot-node-sdk');
|
|
553
|
+
this.connectionState = 'connecting';
|
|
554
|
+
this.lastConnectionError = undefined;
|
|
555
|
+
const client = new sdk.WSClient({
|
|
556
|
+
botId,
|
|
557
|
+
secret,
|
|
558
|
+
heartbeatInterval: 30_000,
|
|
559
|
+
maxReconnectAttempts: 10,
|
|
560
|
+
maxAuthFailureAttempts: 5,
|
|
561
|
+
requestTimeout: 15_000,
|
|
562
|
+
logger: {
|
|
563
|
+
debug: (message) => logger.debug(`[WeCom SDK] ${sanitizeWecomSdkLog(message)}`),
|
|
564
|
+
info: (message, ...args) => logger.info(`[WeCom SDK] ${sanitizeWecomSdkLog(message)}`, ...args),
|
|
565
|
+
warn: (message, ...args) => logger.warn(`[WeCom SDK] ${sanitizeWecomSdkLog(message)}`, ...args),
|
|
566
|
+
error: (message, ...args) => logger.error(`[WeCom SDK] ${sanitizeWecomSdkLog(message)}`, ...args),
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
this.client = client;
|
|
49
570
|
// Message events
|
|
50
|
-
|
|
571
|
+
client.on('message', (frame) => {
|
|
572
|
+
if (this.client !== client)
|
|
573
|
+
return;
|
|
51
574
|
this.handleIncoming(frame).catch((err) => {
|
|
52
575
|
logger.error('[WeCom] Failed to process incoming message:', err);
|
|
53
576
|
});
|
|
54
577
|
});
|
|
55
578
|
// Event callbacks (enter_chat, etc.)
|
|
56
|
-
|
|
579
|
+
client.on('event.enter_chat', (frame) => {
|
|
580
|
+
if (this.client !== client)
|
|
581
|
+
return;
|
|
57
582
|
const body = frame?.body;
|
|
58
583
|
if (body) {
|
|
59
584
|
logger.debug(`[WeCom] User entered chat: userid=${body.from?.userid} chattype=${body.chattype}`);
|
|
60
585
|
}
|
|
61
586
|
});
|
|
62
587
|
// Lifecycle events
|
|
63
|
-
|
|
588
|
+
client.on('authenticated', () => {
|
|
589
|
+
if (this.client !== client)
|
|
590
|
+
return;
|
|
591
|
+
this.connectionState = 'authenticated';
|
|
592
|
+
this.lastConnectionError = undefined;
|
|
64
593
|
logger.info('[WeCom] WebSocket authenticated');
|
|
65
594
|
});
|
|
66
|
-
|
|
595
|
+
client.on('disconnected', (reason) => {
|
|
596
|
+
if (this.client !== client)
|
|
597
|
+
return;
|
|
67
598
|
logger.warn(`[WeCom] WebSocket disconnected: ${reason}`);
|
|
68
|
-
this.
|
|
599
|
+
if (this.connectionState !== 'kicked')
|
|
600
|
+
this.connectionState = 'disconnected';
|
|
601
|
+
this.lastConnectionError = reason;
|
|
602
|
+
this.expireActiveStreams();
|
|
69
603
|
});
|
|
70
|
-
|
|
604
|
+
client.on('reconnecting', (attempt) => {
|
|
605
|
+
if (this.client !== client)
|
|
606
|
+
return;
|
|
607
|
+
this.connectionState = 'reconnecting';
|
|
608
|
+
this.expireActiveStreams();
|
|
71
609
|
logger.info(`[WeCom] Reconnecting (attempt ${attempt})...`);
|
|
72
610
|
});
|
|
73
|
-
|
|
611
|
+
client.on('error', (error) => {
|
|
612
|
+
if (this.client !== client)
|
|
613
|
+
return;
|
|
614
|
+
this.lastConnectionError = error.message;
|
|
615
|
+
if (isWecomAuthError(error))
|
|
616
|
+
this.connectionState = 'auth_failed';
|
|
617
|
+
else if (error.code === 'WS_RECONNECT_EXHAUSTED')
|
|
618
|
+
this.connectionState = 'disconnected';
|
|
74
619
|
logger.error('[WeCom] WebSocket error:', error);
|
|
75
620
|
});
|
|
76
|
-
|
|
77
|
-
|
|
621
|
+
client.on('event.template_card_event', (frame) => {
|
|
622
|
+
if (this.client !== client)
|
|
623
|
+
return;
|
|
624
|
+
this.handleTemplateCardEvent(frame).catch(error => {
|
|
625
|
+
logger.error('[WeCom] Failed to process template card event:', error);
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
client.on('event.disconnected_event', () => {
|
|
629
|
+
if (this.client !== client)
|
|
630
|
+
return;
|
|
631
|
+
this.connectionState = 'kicked';
|
|
632
|
+
this.lastConnectionError = 'A newer connection subscribed with the same Bot ID';
|
|
633
|
+
this.expireActiveStreams();
|
|
634
|
+
logger.error(`[WeCom] Connection kicked for channel ${this.channelKey}; another client is using this Bot ID`);
|
|
635
|
+
});
|
|
636
|
+
client.connect();
|
|
637
|
+
try {
|
|
638
|
+
await this.waitForAuthentication(client, 20_000);
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
const wasCurrentClient = this.client === client;
|
|
642
|
+
if (wasCurrentClient)
|
|
643
|
+
this.client = null;
|
|
644
|
+
try {
|
|
645
|
+
client.disconnect();
|
|
646
|
+
}
|
|
647
|
+
catch { /* ignore */ }
|
|
648
|
+
if (wasCurrentClient) {
|
|
649
|
+
const failedState = this.connectionState;
|
|
650
|
+
if (failedState !== 'auth_failed')
|
|
651
|
+
this.connectionState = 'disconnected';
|
|
652
|
+
this.lastConnectionError = error instanceof Error ? error.message : String(error);
|
|
653
|
+
}
|
|
654
|
+
throw error;
|
|
655
|
+
}
|
|
78
656
|
// Hourly cleanup of old dedup entries
|
|
79
657
|
this.cleanupInterval = setInterval(() => {
|
|
80
658
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
@@ -82,29 +660,39 @@ export class WecomChannel {
|
|
|
82
660
|
if (ts < cutoff)
|
|
83
661
|
this.seenMessages.delete(id);
|
|
84
662
|
}
|
|
663
|
+
this.cleanupStreams();
|
|
85
664
|
}, 60 * 60 * 1000);
|
|
86
665
|
logger.info('[WeCom] Channel connected');
|
|
87
666
|
}
|
|
88
667
|
async disconnect() {
|
|
89
|
-
this.
|
|
668
|
+
this.connectionState = 'idle';
|
|
90
669
|
if (this.cleanupInterval) {
|
|
91
670
|
clearInterval(this.cleanupInterval);
|
|
92
671
|
this.cleanupInterval = null;
|
|
93
672
|
}
|
|
94
|
-
|
|
673
|
+
const client = this.client;
|
|
674
|
+
this.client = null;
|
|
675
|
+
if (client) {
|
|
95
676
|
try {
|
|
96
|
-
|
|
677
|
+
client.disconnect();
|
|
97
678
|
}
|
|
98
679
|
catch { /* ignore */ }
|
|
99
|
-
this.client = null;
|
|
100
680
|
}
|
|
681
|
+
this.activeStreams.clear();
|
|
101
682
|
logger.info('[WeCom] Channel disconnected');
|
|
102
683
|
}
|
|
103
684
|
onMessage(handler) {
|
|
104
685
|
this.messageHandler = handler;
|
|
105
686
|
}
|
|
687
|
+
onProjectPathRequest(provider) {
|
|
688
|
+
this.projectPathProvider = provider;
|
|
689
|
+
}
|
|
106
690
|
getStatus() {
|
|
107
|
-
return {
|
|
691
|
+
return {
|
|
692
|
+
connected: this.connectionState === 'authenticated',
|
|
693
|
+
state: this.connectionState,
|
|
694
|
+
error: this.lastConnectionError,
|
|
695
|
+
};
|
|
108
696
|
}
|
|
109
697
|
async reconnect() {
|
|
110
698
|
await this.disconnect();
|
|
@@ -116,11 +704,144 @@ export class WecomChannel {
|
|
|
116
704
|
return `重连失败: ${err instanceof Error ? err.message : String(err)}`;
|
|
117
705
|
}
|
|
118
706
|
}
|
|
707
|
+
onInteraction(callback) {
|
|
708
|
+
this.interactionCallback = callback;
|
|
709
|
+
}
|
|
710
|
+
onInteractionInvalidated(callback) {
|
|
711
|
+
this.interactionInvalidationCallback = callback;
|
|
712
|
+
}
|
|
713
|
+
invalidateInteraction(interactionId, reason = 'cancelled') {
|
|
714
|
+
this.cardStore?.invalidateByInteractionId(interactionId, reason);
|
|
715
|
+
}
|
|
716
|
+
async withInteractionSendLock(chatId, run) {
|
|
717
|
+
const previous = this.interactionSendTails.get(chatId) ?? Promise.resolve();
|
|
718
|
+
let release;
|
|
719
|
+
const current = new Promise(resolve => { release = resolve; });
|
|
720
|
+
this.interactionSendTails.set(chatId, current);
|
|
721
|
+
await previous.catch(() => undefined);
|
|
722
|
+
try {
|
|
723
|
+
return await run();
|
|
724
|
+
}
|
|
725
|
+
finally {
|
|
726
|
+
release();
|
|
727
|
+
if (this.interactionSendTails.get(chatId) === current)
|
|
728
|
+
this.interactionSendTails.delete(chatId);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
async invalidateSupersededCards(records) {
|
|
732
|
+
for (const record of records) {
|
|
733
|
+
if (this.interactionInvalidationCallback) {
|
|
734
|
+
try {
|
|
735
|
+
await this.interactionInvalidationCallback(record.interaction.id, 'superseded');
|
|
736
|
+
}
|
|
737
|
+
catch (error) {
|
|
738
|
+
logger.warn(`[WeCom] Failed to cancel superseded interaction ${record.interaction.id}: ${String(error)}`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
this.cardStore?.invalidateByInteractionId(record.interaction.id, 'superseded');
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
async waitForAuthentication(client, timeoutMs) {
|
|
745
|
+
if (this.connectionState === 'authenticated')
|
|
746
|
+
return;
|
|
747
|
+
await new Promise((resolve, reject) => {
|
|
748
|
+
let settled = false;
|
|
749
|
+
const finish = (error) => {
|
|
750
|
+
if (settled)
|
|
751
|
+
return;
|
|
752
|
+
settled = true;
|
|
753
|
+
clearTimeout(timer);
|
|
754
|
+
client.off('authenticated', onAuthenticated);
|
|
755
|
+
client.off('error', onError);
|
|
756
|
+
client.off('disconnected', onDisconnected);
|
|
757
|
+
if (error)
|
|
758
|
+
reject(error);
|
|
759
|
+
else
|
|
760
|
+
resolve();
|
|
761
|
+
};
|
|
762
|
+
const onAuthenticated = () => finish();
|
|
763
|
+
const onError = (error) => {
|
|
764
|
+
if (isWecomAuthError(error))
|
|
765
|
+
finish(error);
|
|
766
|
+
};
|
|
767
|
+
const onDisconnected = (reason) => finish(new Error(`WeCom disconnected before authentication: ${reason}`));
|
|
768
|
+
const timer = setTimeout(() => {
|
|
769
|
+
finish(new Error(`WeCom authentication timed out after ${timeoutMs}ms`));
|
|
770
|
+
}, timeoutMs);
|
|
771
|
+
client.on('authenticated', onAuthenticated);
|
|
772
|
+
client.on('error', onError);
|
|
773
|
+
client.on('disconnected', onDisconnected);
|
|
774
|
+
if (this.connectionState === 'authenticated')
|
|
775
|
+
finish();
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
cleanupStreams(now = Date.now()) {
|
|
779
|
+
const cutoff = now - 30 * 60 * 1000;
|
|
780
|
+
for (const [reqId, stream] of this.activeStreams) {
|
|
781
|
+
if (stream.finished || stream.lastUpdatedAt < cutoff)
|
|
782
|
+
this.activeStreams.delete(reqId);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
expireActiveStreams() {
|
|
786
|
+
for (const stream of this.activeStreams.values())
|
|
787
|
+
stream.expired = true;
|
|
788
|
+
}
|
|
789
|
+
createReplyContext(frame, chatId, messageId) {
|
|
790
|
+
const reqId = String(frame.headers?.req_id ?? '').trim();
|
|
791
|
+
const streamId = crypto.randomUUID();
|
|
792
|
+
if (reqId) {
|
|
793
|
+
this.activeStreams.set(reqId, {
|
|
794
|
+
reqId,
|
|
795
|
+
streamId,
|
|
796
|
+
frame: { headers: { ...frame.headers, req_id: reqId } },
|
|
797
|
+
chatId,
|
|
798
|
+
messageId,
|
|
799
|
+
accumulatedText: '',
|
|
800
|
+
createdAt: Date.now(),
|
|
801
|
+
lastUpdatedAt: Date.now(),
|
|
802
|
+
started: false,
|
|
803
|
+
finished: false,
|
|
804
|
+
expired: false,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
replyToMessageId: messageId,
|
|
809
|
+
metadata: {
|
|
810
|
+
wecomReqId: reqId,
|
|
811
|
+
wecomStreamId: streamId,
|
|
812
|
+
wecomMessageId: messageId,
|
|
813
|
+
},
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
streamFromReplyContext(replyContext) {
|
|
817
|
+
const reqId = replyContext?.metadata?.wecomReqId;
|
|
818
|
+
if (typeof reqId !== 'string' || !reqId)
|
|
819
|
+
return undefined;
|
|
820
|
+
return this.activeStreams.get(reqId);
|
|
821
|
+
}
|
|
822
|
+
requireClient() {
|
|
823
|
+
if (!this.client)
|
|
824
|
+
throw new Error('WeCom client is not connected');
|
|
825
|
+
return this.client;
|
|
826
|
+
}
|
|
827
|
+
async sendProactiveMarkdown(chatId, content) {
|
|
828
|
+
const client = this.requireClient();
|
|
829
|
+
for (const chunk of splitUtf8Bytes(content, 20_480)) {
|
|
830
|
+
await client.sendMessage(chatId, {
|
|
831
|
+
msgtype: 'markdown',
|
|
832
|
+
markdown: { content: chunk },
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
}
|
|
119
836
|
// ── Inbound message handling ──────────────────────────────────────────────
|
|
120
837
|
async handleIncoming(frame) {
|
|
121
838
|
const body = frame?.body;
|
|
122
839
|
if (!body)
|
|
123
840
|
return;
|
|
841
|
+
if (body.aibotid && body.aibotid !== this.config.botId) {
|
|
842
|
+
logger.warn(`[WeCom] Ignoring callback for unexpected Bot ID: ${body.aibotid}`);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
124
845
|
const msgId = body.msgid;
|
|
125
846
|
const chattype = body.chattype || 'single';
|
|
126
847
|
const chatid = body.chatid;
|
|
@@ -132,12 +853,12 @@ export class WecomChannel {
|
|
|
132
853
|
return;
|
|
133
854
|
}
|
|
134
855
|
const channelId = this.resolveChatId(chattype, chatid, userid);
|
|
856
|
+
if (!channelId) {
|
|
857
|
+
logger.warn(`[WeCom] Ignoring ${chattype} message without a routable ${chattype === 'group' ? 'chatid' : 'userid'}`);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
135
860
|
const chatTypeNorm = chattype === 'group' ? 'group' : 'private';
|
|
136
|
-
|
|
137
|
-
this.activeStreams.set(channelId, {
|
|
138
|
-
streamId: crypto.randomUUID(),
|
|
139
|
-
frame: { headers: frame.headers },
|
|
140
|
-
});
|
|
861
|
+
const replyContext = this.createReplyContext(frame, channelId, msgId);
|
|
141
862
|
if (!this.messageHandler)
|
|
142
863
|
return;
|
|
143
864
|
// 首次交互欢迎消息(使用共享帮助函数)
|
|
@@ -146,85 +867,143 @@ export class WecomChannel {
|
|
|
146
867
|
const text = body.text?.content?.trim();
|
|
147
868
|
if (!text)
|
|
148
869
|
return;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (body.quote) {
|
|
152
|
-
const quoteText = body.quote.text?.content || '';
|
|
153
|
-
if (quoteText) {
|
|
154
|
-
content = `[引用: ${quoteText}]\n${text}`;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
870
|
+
const quote = await this.processQuote(body.quote, channelId);
|
|
871
|
+
const content = quote.prefix ? `${quote.prefix}\n${text}` : text;
|
|
157
872
|
await this.messageHandler({
|
|
158
873
|
channelId, content, chatType: chatTypeNorm,
|
|
159
874
|
peerId: userid, messageId: msgId,
|
|
875
|
+
images: quote.images.length > 0 ? quote.images : undefined,
|
|
876
|
+
replyContext,
|
|
160
877
|
});
|
|
161
878
|
}
|
|
162
879
|
else if (msgtype === 'image') {
|
|
163
|
-
await this.handleImageMessage(body, channelId, chatTypeNorm, userid, msgId,
|
|
880
|
+
await this.handleImageMessage(body, channelId, chatTypeNorm, userid, msgId, replyContext);
|
|
164
881
|
}
|
|
165
882
|
else if (msgtype === 'voice') {
|
|
166
883
|
const voiceText = body.voice?.content?.trim();
|
|
167
884
|
if (voiceText) {
|
|
168
885
|
await this.messageHandler({
|
|
169
886
|
channelId, content: voiceText, chatType: chatTypeNorm,
|
|
170
|
-
peerId: userid, messageId: msgId,
|
|
887
|
+
peerId: userid, messageId: msgId, replyContext,
|
|
171
888
|
});
|
|
172
889
|
}
|
|
173
890
|
}
|
|
174
891
|
else if (msgtype === 'file') {
|
|
175
|
-
await this.handleFileMessage(body, channelId, chatTypeNorm, userid, msgId,
|
|
892
|
+
await this.handleFileMessage(body, channelId, chatTypeNorm, userid, msgId, replyContext);
|
|
176
893
|
}
|
|
177
894
|
else if (msgtype === 'video') {
|
|
178
|
-
await this.handleVideoMessage(body, channelId, chatTypeNorm, userid, msgId,
|
|
895
|
+
await this.handleVideoMessage(body, channelId, chatTypeNorm, userid, msgId, replyContext);
|
|
179
896
|
}
|
|
180
897
|
else if (msgtype === 'mixed') {
|
|
181
|
-
await this.handleMixedMessage(body, channelId, chatTypeNorm, userid, msgId,
|
|
898
|
+
await this.handleMixedMessage(body, channelId, chatTypeNorm, userid, msgId, replyContext);
|
|
182
899
|
}
|
|
183
900
|
else {
|
|
184
901
|
await this.messageHandler({
|
|
185
902
|
channelId,
|
|
186
903
|
content: `[不支持的消息类型: ${msgtype}]`,
|
|
187
|
-
chatType: chatTypeNorm, peerId: userid, messageId: msgId,
|
|
904
|
+
chatType: chatTypeNorm, peerId: userid, messageId: msgId, replyContext,
|
|
188
905
|
});
|
|
189
906
|
}
|
|
190
907
|
}
|
|
191
908
|
// ── Inbound media handling ────────────────────────────────────────────────
|
|
192
|
-
async
|
|
909
|
+
async downloadInboundMedia(url, aesKey) {
|
|
910
|
+
if (!this.client)
|
|
911
|
+
throw new Error('WeCom client is unavailable');
|
|
912
|
+
validateWecomMediaUrl(url);
|
|
913
|
+
const result = await this.client.downloadFile(url, aesKey);
|
|
914
|
+
const maxBytes = 20 * 1024 * 1024;
|
|
915
|
+
if (result.buffer.length > maxBytes) {
|
|
916
|
+
throw new Error(`WeCom media exceeds ${maxBytes} bytes`);
|
|
917
|
+
}
|
|
918
|
+
return result;
|
|
919
|
+
}
|
|
920
|
+
async processQuote(quote, channelId) {
|
|
921
|
+
const result = { prefix: '', images: [] };
|
|
922
|
+
if (!quote?.msgtype)
|
|
923
|
+
return result;
|
|
924
|
+
try {
|
|
925
|
+
if (quote.msgtype === 'text') {
|
|
926
|
+
const text = String(quote.text?.content ?? '').trim();
|
|
927
|
+
if (text)
|
|
928
|
+
result.prefix = `[引用文本]\n${text}`;
|
|
929
|
+
return result;
|
|
930
|
+
}
|
|
931
|
+
if (quote.msgtype === 'voice') {
|
|
932
|
+
const text = String(quote.voice?.content ?? '').trim();
|
|
933
|
+
if (text)
|
|
934
|
+
result.prefix = `[引用语音转写]\n${text}`;
|
|
935
|
+
return result;
|
|
936
|
+
}
|
|
937
|
+
if (quote.msgtype === 'image' && quote.image?.url) {
|
|
938
|
+
const media = await this.downloadInboundMedia(quote.image.url, quote.image.aeskey);
|
|
939
|
+
const image = await bufferToInboundImage(media.buffer, { filename: media.filename });
|
|
940
|
+
if (!image)
|
|
941
|
+
throw new Error('引用图片格式不受支持');
|
|
942
|
+
result.images.push(image);
|
|
943
|
+
result.prefix = '[引用图片]';
|
|
944
|
+
return result;
|
|
945
|
+
}
|
|
946
|
+
if (quote.msgtype === 'file' && quote.file?.url) {
|
|
947
|
+
const media = await this.downloadInboundMedia(quote.file.url, quote.file.aeskey);
|
|
948
|
+
const fileName = sanitizeFileName(quote.file.filename || media.filename || 'quoted-file');
|
|
949
|
+
const projectPath = this.projectPathProvider
|
|
950
|
+
? await this.projectPathProvider(channelId)
|
|
951
|
+
: process.cwd();
|
|
952
|
+
const saved = saveToUploads(media.buffer, fileName, projectPath);
|
|
953
|
+
result.prefix = `[引用文件: ${saved.fileName}]\n文件已保存到:${saved.filePath}\n请使用 Read 工具读取并分析文件内容。`;
|
|
954
|
+
return result;
|
|
955
|
+
}
|
|
956
|
+
if (quote.msgtype === 'mixed' && Array.isArray(quote.mixed?.msg_item)) {
|
|
957
|
+
const textParts = [];
|
|
958
|
+
for (const item of quote.mixed.msg_item) {
|
|
959
|
+
if (item.msgtype === 'text' && item.text?.content) {
|
|
960
|
+
textParts.push(String(item.text.content));
|
|
961
|
+
}
|
|
962
|
+
else if (item.msgtype === 'image' && item.image?.url) {
|
|
963
|
+
const media = await this.downloadInboundMedia(item.image.url, item.image.aeskey);
|
|
964
|
+
const image = await bufferToInboundImage(media.buffer, { filename: media.filename });
|
|
965
|
+
if (image)
|
|
966
|
+
result.images.push(image);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
result.prefix = `[引用图文]\n${textParts.join('')}`.trim();
|
|
970
|
+
return result;
|
|
971
|
+
}
|
|
972
|
+
result.prefix = `[引用了暂不支持的消息类型: ${quote.msgtype}]`;
|
|
973
|
+
}
|
|
974
|
+
catch (error) {
|
|
975
|
+
logger.warn(`[WeCom] Failed to process quote (${quote.msgtype}): ${String(error)}`);
|
|
976
|
+
result.prefix = `[引用内容读取失败: ${quote.msgtype}]`;
|
|
977
|
+
}
|
|
978
|
+
return result;
|
|
979
|
+
}
|
|
980
|
+
async handleImageMessage(body, channelId, chatType, peerId, msgId, replyContext) {
|
|
193
981
|
const imageUrl = body.image?.url;
|
|
194
982
|
const aeskey = body.image?.aeskey;
|
|
195
983
|
if (!imageUrl) {
|
|
196
984
|
logger.warn('[WeCom] Image message without url');
|
|
197
985
|
await this.messageHandler({
|
|
198
986
|
channelId, content: '[图片下载失败:缺少下载链接]',
|
|
199
|
-
chatType, peerId, messageId: msgId,
|
|
987
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
200
988
|
});
|
|
201
989
|
return;
|
|
202
990
|
}
|
|
203
991
|
try {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
buffer = result.buffer;
|
|
208
|
-
}
|
|
209
|
-
else {
|
|
210
|
-
const { safeFetch } = await import('../utils/media-cache.js');
|
|
211
|
-
buffer = await safeFetch(imageUrl, { skipSsrfCheck: true });
|
|
212
|
-
}
|
|
213
|
-
const { validateImage } = await import('../utils/media-cache.js');
|
|
214
|
-
const result = await validateImage(buffer);
|
|
215
|
-
if (result.mime) {
|
|
992
|
+
const media = await this.downloadInboundMedia(imageUrl, aeskey);
|
|
993
|
+
const image = await bufferToInboundImage(media.buffer, { filename: media.filename });
|
|
994
|
+
if (image) {
|
|
216
995
|
await this.messageHandler({
|
|
217
996
|
channelId,
|
|
218
997
|
content: '用户发送了一张图片,请分析这张图片的内容。',
|
|
219
998
|
chatType, peerId, messageId: msgId,
|
|
220
|
-
images: [
|
|
999
|
+
images: [image], replyContext,
|
|
221
1000
|
});
|
|
222
1001
|
}
|
|
223
1002
|
else {
|
|
224
1003
|
logger.warn(`[WeCom] Image validation failed`);
|
|
225
1004
|
await this.messageHandler({
|
|
226
1005
|
channelId, content: '[图片验证失败]',
|
|
227
|
-
chatType, peerId, messageId: msgId,
|
|
1006
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
228
1007
|
});
|
|
229
1008
|
}
|
|
230
1009
|
}
|
|
@@ -232,97 +1011,83 @@ export class WecomChannel {
|
|
|
232
1011
|
logger.error('[WeCom] Failed to download image:', error);
|
|
233
1012
|
await this.messageHandler({
|
|
234
1013
|
channelId, content: '[图片下载失败]',
|
|
235
|
-
chatType, peerId, messageId: msgId,
|
|
1014
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
236
1015
|
});
|
|
237
1016
|
}
|
|
238
1017
|
}
|
|
239
|
-
async handleFileMessage(body, channelId, chatType, peerId, msgId,
|
|
1018
|
+
async handleFileMessage(body, channelId, chatType, peerId, msgId, replyContext) {
|
|
240
1019
|
const fileUrl = body.file?.url;
|
|
241
1020
|
const aeskey = body.file?.aeskey;
|
|
242
|
-
const
|
|
1021
|
+
const callbackFileName = body.file?.filename
|
|
1022
|
+
? sanitizeFileName(body.file.filename)
|
|
1023
|
+
: undefined;
|
|
1024
|
+
const displayFileName = callbackFileName || 'file';
|
|
243
1025
|
if (!fileUrl) {
|
|
244
1026
|
logger.warn('[WeCom] File message without url');
|
|
245
1027
|
await this.messageHandler({
|
|
246
|
-
channelId, content: `[文件下载失败:缺少下载链接] ${
|
|
247
|
-
chatType, peerId, messageId: msgId,
|
|
1028
|
+
channelId, content: `[文件下载失败:缺少下载链接] ${displayFileName}`,
|
|
1029
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
248
1030
|
});
|
|
249
1031
|
return;
|
|
250
1032
|
}
|
|
251
1033
|
try {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const result = await this.client.downloadFile(fileUrl, aeskey);
|
|
255
|
-
buffer = result.buffer;
|
|
256
|
-
}
|
|
257
|
-
else {
|
|
258
|
-
const { safeFetch } = await import('../utils/media-cache.js');
|
|
259
|
-
buffer = await safeFetch(fileUrl, { skipSsrfCheck: true });
|
|
260
|
-
}
|
|
261
|
-
const { saveToUploads, sanitizeFileName } = await import('../utils/media-cache.js');
|
|
1034
|
+
const media = await this.downloadInboundMedia(fileUrl, aeskey);
|
|
1035
|
+
const fileName = sanitizeFileName(callbackFileName || media.filename || 'file');
|
|
262
1036
|
const projectPath = this.projectPathProvider
|
|
263
1037
|
? await this.projectPathProvider(channelId)
|
|
264
1038
|
: process.cwd();
|
|
265
|
-
const { filePath } = saveToUploads(buffer,
|
|
1039
|
+
const { filePath } = saveToUploads(media.buffer, fileName, projectPath);
|
|
266
1040
|
await this.messageHandler({
|
|
267
1041
|
channelId,
|
|
268
1042
|
content: `用户发送了文件:${fileName}\n文件已保存到:${filePath}\n请使用 Read 工具读取并分析文件内容。`,
|
|
269
|
-
chatType, peerId, messageId: msgId,
|
|
1043
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
270
1044
|
});
|
|
271
1045
|
}
|
|
272
1046
|
catch (error) {
|
|
273
1047
|
logger.error('[WeCom] Failed to download file:', error);
|
|
274
1048
|
await this.messageHandler({
|
|
275
|
-
channelId, content: `[文件下载失败] ${
|
|
276
|
-
chatType, peerId, messageId: msgId,
|
|
1049
|
+
channelId, content: `[文件下载失败] ${displayFileName}`,
|
|
1050
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
277
1051
|
});
|
|
278
1052
|
}
|
|
279
1053
|
}
|
|
280
|
-
async handleVideoMessage(body, channelId, chatType, peerId, msgId,
|
|
1054
|
+
async handleVideoMessage(body, channelId, chatType, peerId, msgId, replyContext) {
|
|
281
1055
|
const videoUrl = body.video?.url;
|
|
282
1056
|
const aeskey = body.video?.aeskey;
|
|
283
1057
|
if (!videoUrl) {
|
|
284
1058
|
await this.messageHandler({
|
|
285
1059
|
channelId, content: '[视频下载失败:缺少下载链接]',
|
|
286
|
-
chatType, peerId, messageId: msgId,
|
|
1060
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
287
1061
|
});
|
|
288
1062
|
return;
|
|
289
1063
|
}
|
|
290
1064
|
try {
|
|
291
|
-
|
|
292
|
-
if (this.client && aeskey) {
|
|
293
|
-
const result = await this.client.downloadFile(videoUrl, aeskey);
|
|
294
|
-
buffer = result.buffer;
|
|
295
|
-
}
|
|
296
|
-
else {
|
|
297
|
-
const { safeFetch } = await import('../utils/media-cache.js');
|
|
298
|
-
buffer = await safeFetch(videoUrl, { skipSsrfCheck: true });
|
|
299
|
-
}
|
|
300
|
-
const { saveToUploads } = await import('../utils/media-cache.js');
|
|
1065
|
+
const media = await this.downloadInboundMedia(videoUrl, aeskey);
|
|
301
1066
|
const projectPath = this.projectPathProvider
|
|
302
1067
|
? await this.projectPathProvider(channelId)
|
|
303
1068
|
: process.cwd();
|
|
304
1069
|
const fileName = `video_${Date.now()}.mp4`;
|
|
305
|
-
const { filePath } = saveToUploads(buffer, fileName, projectPath);
|
|
1070
|
+
const { filePath } = saveToUploads(media.buffer, fileName, projectPath);
|
|
306
1071
|
await this.messageHandler({
|
|
307
1072
|
channelId,
|
|
308
1073
|
content: `用户发送了视频:${fileName}\n文件已保存到:${filePath}`,
|
|
309
|
-
chatType, peerId, messageId: msgId,
|
|
1074
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
310
1075
|
});
|
|
311
1076
|
}
|
|
312
1077
|
catch (error) {
|
|
313
1078
|
logger.error('[WeCom] Failed to download video:', error);
|
|
314
1079
|
await this.messageHandler({
|
|
315
1080
|
channelId, content: '[视频下载失败]',
|
|
316
|
-
chatType, peerId, messageId: msgId,
|
|
1081
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
317
1082
|
});
|
|
318
1083
|
}
|
|
319
1084
|
}
|
|
320
|
-
async handleMixedMessage(body, channelId, chatType, peerId, msgId,
|
|
1085
|
+
async handleMixedMessage(body, channelId, chatType, peerId, msgId, replyContext) {
|
|
321
1086
|
const msgItems = body.mixed?.msg_item;
|
|
322
1087
|
if (!Array.isArray(msgItems)) {
|
|
323
1088
|
await this.messageHandler({
|
|
324
1089
|
channelId, content: '[不支持的图文混排格式]',
|
|
325
|
-
chatType, peerId, messageId: msgId,
|
|
1090
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
326
1091
|
});
|
|
327
1092
|
return;
|
|
328
1093
|
}
|
|
@@ -334,20 +1099,10 @@ export class WecomChannel {
|
|
|
334
1099
|
}
|
|
335
1100
|
else if (item.msgtype === 'image' && item.image?.url) {
|
|
336
1101
|
try {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
else {
|
|
343
|
-
const { safeFetch } = await import('../utils/media-cache.js');
|
|
344
|
-
buffer = await safeFetch(item.image.url, { skipSsrfCheck: true });
|
|
345
|
-
}
|
|
346
|
-
const { validateImage } = await import('../utils/media-cache.js');
|
|
347
|
-
const result = await validateImage(buffer);
|
|
348
|
-
if (result.mime) {
|
|
349
|
-
images.push({ data: buffer.toString('base64'), mimeType: result.mime });
|
|
350
|
-
}
|
|
1102
|
+
const media = await this.downloadInboundMedia(item.image.url, item.image.aeskey);
|
|
1103
|
+
const image = await bufferToInboundImage(media.buffer, { filename: media.filename });
|
|
1104
|
+
if (image)
|
|
1105
|
+
images.push(image);
|
|
351
1106
|
}
|
|
352
1107
|
catch (error) {
|
|
353
1108
|
logger.warn('[WeCom] Failed to download mixed image:', error);
|
|
@@ -359,81 +1114,132 @@ export class WecomChannel {
|
|
|
359
1114
|
channelId, content: prompt, chatType,
|
|
360
1115
|
peerId, messageId: msgId,
|
|
361
1116
|
images: images.length > 0 ? images : undefined,
|
|
1117
|
+
replyContext,
|
|
362
1118
|
});
|
|
363
1119
|
}
|
|
364
1120
|
// ── Outbound: text ────────────────────────────────────────────────────────
|
|
365
|
-
async sendMessage(chatId, content) {
|
|
1121
|
+
async sendMessage(chatId, content, replyContext) {
|
|
1122
|
+
return this.sendStreamText(chatId, content, true, replyContext);
|
|
1123
|
+
}
|
|
1124
|
+
async sendStreamText(chatId, content, isFinal, replyContext) {
|
|
366
1125
|
if (!content || content.trim() === '') {
|
|
367
1126
|
logger.warn('[WeCom] Attempted to send empty message, skipping');
|
|
368
1127
|
return;
|
|
369
1128
|
}
|
|
370
|
-
|
|
371
|
-
logger.error('[WeCom] Client not connected, cannot send message');
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
1129
|
+
const client = this.requireClient();
|
|
374
1130
|
try {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
1131
|
+
const stream = this.streamFromReplyContext(replyContext);
|
|
1132
|
+
if (stream && !stream.finished) {
|
|
1133
|
+
stream.accumulatedText += content;
|
|
1134
|
+
stream.lastUpdatedAt = Date.now();
|
|
1135
|
+
const visibleText = truncateUtf8Bytes(stream.accumulatedText, 20_480);
|
|
1136
|
+
if (!stream.expired) {
|
|
1137
|
+
try {
|
|
1138
|
+
if (isFinal) {
|
|
1139
|
+
await client.replyStream(stream.frame, stream.streamId, visibleText, true);
|
|
1140
|
+
}
|
|
1141
|
+
else {
|
|
1142
|
+
const result = await client.replyStreamNonBlocking(stream.frame, stream.streamId, visibleText, false);
|
|
1143
|
+
if (result === 'skipped')
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
stream.started = true;
|
|
1147
|
+
}
|
|
1148
|
+
catch (error) {
|
|
1149
|
+
if (wecomErrorCode(error) === 846608) {
|
|
1150
|
+
stream.expired = true;
|
|
1151
|
+
logger.warn(`[WeCom] Stream expired for reqId=${stream.reqId}; final reply will use proactive send`);
|
|
1152
|
+
}
|
|
1153
|
+
else {
|
|
1154
|
+
throw error;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
if (isFinal) {
|
|
1159
|
+
stream.finished = true;
|
|
1160
|
+
if (stream.expired) {
|
|
1161
|
+
await this.sendProactiveMarkdown(chatId, stream.accumulatedText);
|
|
1162
|
+
}
|
|
1163
|
+
else {
|
|
1164
|
+
const chunks = splitUtf8Bytes(stream.accumulatedText, 20_480);
|
|
1165
|
+
for (const continuation of chunks.slice(1))
|
|
1166
|
+
await this.sendProactiveMarkdown(chatId, continuation);
|
|
1167
|
+
}
|
|
1168
|
+
logger.debug(`[WeCom] Finished stream reply reqId=${stream.reqId} chatId=${chatId}`);
|
|
1169
|
+
}
|
|
381
1170
|
return;
|
|
382
1171
|
}
|
|
383
|
-
|
|
384
|
-
await this.client.sendMessage(chatId, {
|
|
385
|
-
msgtype: 'markdown',
|
|
386
|
-
markdown: { content },
|
|
387
|
-
});
|
|
1172
|
+
await this.sendProactiveMarkdown(chatId, content);
|
|
388
1173
|
logger.debug(`[WeCom] Sent proactive message to ${chatId}`);
|
|
389
1174
|
}
|
|
390
1175
|
catch (error) {
|
|
391
1176
|
logger.error(`[WeCom] sendMessage failed for ${chatId}:`, error.message);
|
|
1177
|
+
throw error;
|
|
392
1178
|
}
|
|
393
1179
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
if (!
|
|
397
|
-
logger.warn('[WeCom] Client not connected for sendImage');
|
|
1180
|
+
async finishStream(replyContext, fallbackText = '已完成') {
|
|
1181
|
+
const stream = this.streamFromReplyContext(replyContext);
|
|
1182
|
+
if (!stream || stream.finished)
|
|
398
1183
|
return;
|
|
1184
|
+
const client = this.requireClient();
|
|
1185
|
+
const content = stream.accumulatedText || fallbackText;
|
|
1186
|
+
if (stream.expired) {
|
|
1187
|
+
stream.finished = true;
|
|
1188
|
+
await this.sendProactiveMarkdown(stream.chatId, content);
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
try {
|
|
1192
|
+
await client.replyStream(stream.frame, stream.streamId, truncateUtf8Bytes(content, 20_480), true);
|
|
1193
|
+
stream.finished = true;
|
|
1194
|
+
stream.lastUpdatedAt = Date.now();
|
|
1195
|
+
const chunks = splitUtf8Bytes(content, 20_480);
|
|
1196
|
+
for (const continuation of chunks.slice(1))
|
|
1197
|
+
await this.sendProactiveMarkdown(stream.chatId, continuation);
|
|
1198
|
+
}
|
|
1199
|
+
catch (error) {
|
|
1200
|
+
if (wecomErrorCode(error) !== 846608)
|
|
1201
|
+
throw error;
|
|
1202
|
+
stream.expired = true;
|
|
1203
|
+
stream.finished = true;
|
|
1204
|
+
await this.sendProactiveMarkdown(stream.chatId, content);
|
|
399
1205
|
}
|
|
1206
|
+
}
|
|
1207
|
+
// ── Outbound: image ───────────────────────────────────────────────────────
|
|
1208
|
+
async sendImage(chatId, png, replyContext) {
|
|
1209
|
+
const client = this.requireClient();
|
|
400
1210
|
try {
|
|
401
|
-
const result = await
|
|
1211
|
+
const result = await client.uploadMedia(png, {
|
|
402
1212
|
type: 'image',
|
|
403
1213
|
filename: 'image.png',
|
|
404
1214
|
});
|
|
405
1215
|
const mediaId = result?.media_id;
|
|
406
1216
|
if (!mediaId) {
|
|
407
|
-
|
|
408
|
-
return;
|
|
1217
|
+
throw new Error('WeCom media upload failed: no media_id');
|
|
409
1218
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
this.activeStreams.delete(chatId);
|
|
1219
|
+
const stream = this.streamFromReplyContext(replyContext);
|
|
1220
|
+
if (stream && !stream.finished && !stream.started) {
|
|
1221
|
+
await client.replyMedia(stream.frame, 'image', mediaId);
|
|
1222
|
+
stream.finished = true;
|
|
415
1223
|
}
|
|
416
1224
|
else {
|
|
417
|
-
await this.
|
|
1225
|
+
await this.finishStream(replyContext);
|
|
1226
|
+
await client.sendMediaMessage(chatId, 'image', mediaId);
|
|
418
1227
|
}
|
|
419
1228
|
logger.debug(`[WeCom] Sent image to ${chatId}`);
|
|
420
1229
|
}
|
|
421
1230
|
catch (error) {
|
|
422
1231
|
logger.error(`[WeCom] sendImage failed for ${chatId}:`, error.message);
|
|
1232
|
+
throw error;
|
|
423
1233
|
}
|
|
424
1234
|
}
|
|
425
1235
|
// ── Outbound: file ────────────────────────────────────────────────────────
|
|
426
|
-
async sendFile(chatId, filePath) {
|
|
427
|
-
|
|
428
|
-
logger.warn('[WeCom] Client not connected for sendFile');
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
1236
|
+
async sendFile(chatId, filePath, replyContext) {
|
|
1237
|
+
const client = this.requireClient();
|
|
431
1238
|
try {
|
|
432
1239
|
const fs = await import('fs');
|
|
433
1240
|
const path = await import('path');
|
|
434
1241
|
if (!fs.existsSync(filePath)) {
|
|
435
|
-
|
|
436
|
-
return;
|
|
1242
|
+
throw new Error(`WeCom file not found: ${filePath}`);
|
|
437
1243
|
}
|
|
438
1244
|
// Detect image files → route to sendImage
|
|
439
1245
|
const header = Buffer.alloc(12);
|
|
@@ -444,31 +1250,159 @@ export class WecomChannel {
|
|
|
444
1250
|
const ftype = await fileTypeFromBuffer(header);
|
|
445
1251
|
if (ftype && ftype.mime.startsWith('image/')) {
|
|
446
1252
|
const buf = fs.readFileSync(filePath);
|
|
447
|
-
return this.sendImage(chatId, buf);
|
|
1253
|
+
return this.sendImage(chatId, buf, replyContext);
|
|
448
1254
|
}
|
|
449
1255
|
const buf = fs.readFileSync(filePath);
|
|
450
1256
|
const fileName = path.basename(filePath);
|
|
451
|
-
const result = await
|
|
1257
|
+
const result = await client.uploadMedia(buf, {
|
|
452
1258
|
type: 'file',
|
|
453
1259
|
filename: fileName,
|
|
454
1260
|
});
|
|
455
1261
|
const mediaId = result?.media_id;
|
|
456
1262
|
if (!mediaId) {
|
|
457
|
-
|
|
458
|
-
return;
|
|
1263
|
+
throw new Error('WeCom file upload failed: no media_id');
|
|
459
1264
|
}
|
|
460
|
-
const stream = this.
|
|
461
|
-
if (stream) {
|
|
462
|
-
await
|
|
463
|
-
|
|
1265
|
+
const stream = this.streamFromReplyContext(replyContext);
|
|
1266
|
+
if (stream && !stream.finished && !stream.started) {
|
|
1267
|
+
await client.replyMedia(stream.frame, 'file', mediaId);
|
|
1268
|
+
stream.finished = true;
|
|
464
1269
|
}
|
|
465
1270
|
else {
|
|
466
|
-
await this.
|
|
1271
|
+
await this.finishStream(replyContext);
|
|
1272
|
+
await client.sendMediaMessage(chatId, 'file', mediaId);
|
|
467
1273
|
}
|
|
468
1274
|
logger.debug(`[WeCom] Sent file ${fileName} to ${chatId}`);
|
|
469
1275
|
}
|
|
470
1276
|
catch (error) {
|
|
471
1277
|
logger.error(`[WeCom] sendFile failed for ${chatId}:`, error.message);
|
|
1278
|
+
throw error;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
async sendInteraction(chatId, interaction, replyContext) {
|
|
1282
|
+
if (!this.cardStore)
|
|
1283
|
+
return false;
|
|
1284
|
+
return this.withInteractionSendLock(chatId, async () => {
|
|
1285
|
+
const client = this.requireClient();
|
|
1286
|
+
const superseded = this.cardStore.listPendingByChat(chatId)
|
|
1287
|
+
.filter(record => record.interaction.id !== interaction.id);
|
|
1288
|
+
const taskId = createWecomCardTaskId(this.channelKey, interaction.id);
|
|
1289
|
+
const card = buildWecomInteractionCard(interaction, taskId);
|
|
1290
|
+
const record = {
|
|
1291
|
+
taskId,
|
|
1292
|
+
interaction,
|
|
1293
|
+
chatId,
|
|
1294
|
+
createdAt: Date.now(),
|
|
1295
|
+
updatedAt: Date.now(),
|
|
1296
|
+
expiresAt: interaction.expiresAt,
|
|
1297
|
+
status: 'pending',
|
|
1298
|
+
};
|
|
1299
|
+
const stream = this.streamFromReplyContext(replyContext);
|
|
1300
|
+
try {
|
|
1301
|
+
if (stream && !stream.finished) {
|
|
1302
|
+
if (stream.started) {
|
|
1303
|
+
await client.replyStreamWithCard(stream.frame, stream.streamId, truncateUtf8Bytes(stream.accumulatedText, 20_480), true, { templateCard: card });
|
|
1304
|
+
const chunks = splitUtf8Bytes(stream.accumulatedText, 20_480);
|
|
1305
|
+
for (const continuation of chunks.slice(1))
|
|
1306
|
+
await this.sendProactiveMarkdown(chatId, continuation);
|
|
1307
|
+
}
|
|
1308
|
+
else {
|
|
1309
|
+
await client.replyTemplateCard(stream.frame, card);
|
|
1310
|
+
}
|
|
1311
|
+
stream.finished = true;
|
|
1312
|
+
}
|
|
1313
|
+
else {
|
|
1314
|
+
await client.sendMessage(chatId, {
|
|
1315
|
+
msgtype: 'template_card',
|
|
1316
|
+
template_card: card,
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
this.cardStore.set(record);
|
|
1320
|
+
await this.invalidateSupersededCards(superseded);
|
|
1321
|
+
logger.info(`[WeCom] Sent interaction card interaction=${interaction.id} taskId=${taskId}`);
|
|
1322
|
+
return taskId;
|
|
1323
|
+
}
|
|
1324
|
+
catch (error) {
|
|
1325
|
+
logger.error(`[WeCom] Failed to send interaction card ${interaction.id}: ${String(error)}`);
|
|
1326
|
+
throw error;
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
async handleTemplateCardEvent(frame) {
|
|
1331
|
+
const body = frame.body;
|
|
1332
|
+
const client = this.client;
|
|
1333
|
+
if (!body || !client || !this.cardStore)
|
|
1334
|
+
return;
|
|
1335
|
+
if (body.aibotid && body.aibotid !== this.config.botId) {
|
|
1336
|
+
logger.warn(`[WeCom] Ignoring card callback for unexpected Bot ID: ${body.aibotid}`);
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
const duplicate = body.msgid ? this.isDuplicate(body.msgid) : false;
|
|
1340
|
+
if (duplicate)
|
|
1341
|
+
logger.debug(`[WeCom] Replaying terminal response for duplicate card callback: ${body.msgid}`);
|
|
1342
|
+
const rawEvent = body.event;
|
|
1343
|
+
const event = (rawEvent?.template_card_event ?? rawEvent);
|
|
1344
|
+
const taskId = String(event?.task_id ?? '').trim();
|
|
1345
|
+
if (!taskId)
|
|
1346
|
+
return;
|
|
1347
|
+
const record = this.cardStore.get(taskId);
|
|
1348
|
+
if (!record) {
|
|
1349
|
+
logger.warn(`[WeCom] Card callback has no local record: taskId=${taskId}`);
|
|
1350
|
+
await client.updateTemplateCard(frame, buildWecomUnknownCard(taskId), body.from?.userid ? [body.from.userid] : undefined);
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
const operatorId = body.from?.userid;
|
|
1354
|
+
if (record.interaction.initiatorId && operatorId !== record.interaction.initiatorId) {
|
|
1355
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'rejected'), operatorId ? [operatorId] : undefined);
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
if (record.status !== 'pending' || (record.expiresAt && Date.now() > record.expiresAt)) {
|
|
1359
|
+
const reason = record.invalidationReason || (record.status === 'resolved' ? '已处理' : '已过期');
|
|
1360
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'invalidated', reason), operatorId ? [operatorId] : undefined);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
if (this.cardCallbacksInFlight.has(taskId)) {
|
|
1364
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'unavailable', '正在处理'), operatorId ? [operatorId] : undefined);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
this.cardCallbacksInFlight.add(taskId);
|
|
1368
|
+
try {
|
|
1369
|
+
const parsed = parseWecomCardResponse(record, event, operatorId);
|
|
1370
|
+
if (!parsed) {
|
|
1371
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'invalidated', '无法识别操作'), operatorId ? [operatorId] : undefined);
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
if (parsed.command) {
|
|
1375
|
+
if (!this.messageHandler) {
|
|
1376
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'unavailable'), operatorId ? [operatorId] : undefined);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
await this.messageHandler({
|
|
1380
|
+
channelId: record.chatId,
|
|
1381
|
+
content: parsed.command,
|
|
1382
|
+
chatType: body.chattype === 'group' ? 'group' : 'private',
|
|
1383
|
+
peerId: operatorId || '',
|
|
1384
|
+
messageId: `card-trigger-${body.msgid || Date.now()}`,
|
|
1385
|
+
source: 'card-trigger',
|
|
1386
|
+
});
|
|
1387
|
+
this.cardStore.resolve(taskId, parsed.command);
|
|
1388
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'resolved', parsed.command));
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
if (!parsed.response || !this.interactionCallback) {
|
|
1392
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'unavailable'), operatorId ? [operatorId] : undefined);
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
const accepted = (await this.interactionCallback(parsed.response)) !== false;
|
|
1396
|
+
if (!accepted) {
|
|
1397
|
+
const invalidated = this.cardStore.invalidateByInteractionId(record.interaction.id, 'backend_rejected') || record;
|
|
1398
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(invalidated, 'invalidated', '请求已失效'));
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
this.cardStore.resolve(taskId, parsed.response.action);
|
|
1402
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'resolved', parsed.response.action));
|
|
1403
|
+
}
|
|
1404
|
+
finally {
|
|
1405
|
+
this.cardCallbacksInFlight.delete(taskId);
|
|
472
1406
|
}
|
|
473
1407
|
}
|
|
474
1408
|
}
|
|
@@ -491,45 +1425,77 @@ export class WecomChannelPlugin {
|
|
|
491
1425
|
const adapter = {
|
|
492
1426
|
channelName: inst.name,
|
|
493
1427
|
channelKey: inst.name,
|
|
494
|
-
capabilities: { file: true, image: true, interaction:
|
|
1428
|
+
capabilities: { file: true, image: true, interaction: true, markdown: true, thought: false, status: true, thread: false, authenticatedApproval: true },
|
|
495
1429
|
send: async (envelope, payload) => {
|
|
496
1430
|
const channelId = envelope.channelId;
|
|
497
1431
|
switch (payload.kind) {
|
|
498
1432
|
case 'result.text':
|
|
1433
|
+
await channel.sendStreamText(channelId, payload.text, payload.isFinal, envelope.replyContext);
|
|
1434
|
+
return;
|
|
499
1435
|
case 'command.result':
|
|
500
1436
|
case 'command.error':
|
|
501
1437
|
case 'system.notice':
|
|
502
1438
|
case 'system.error':
|
|
503
1439
|
case 'result.error':
|
|
504
|
-
await channel.sendMessage(channelId, payload.text);
|
|
1440
|
+
await channel.sendMessage(channelId, payload.text, envelope.replyContext);
|
|
505
1441
|
return;
|
|
506
1442
|
case 'result.file':
|
|
507
|
-
await channel.sendFile(channelId, payload.filePath);
|
|
1443
|
+
await channel.sendFile(channelId, payload.filePath, envelope.replyContext);
|
|
508
1444
|
return;
|
|
509
1445
|
case 'result.image':
|
|
510
|
-
await channel.sendImage(channelId, payload.data);
|
|
1446
|
+
await channel.sendImage(channelId, payload.data, envelope.replyContext);
|
|
511
1447
|
return;
|
|
512
1448
|
case 'activity.batch': {
|
|
513
1449
|
const filtered = payload.items.filter((i) => !(i.kind === 'tool_result' && i.ok));
|
|
514
1450
|
const text = formatItemsAsText(filtered);
|
|
515
1451
|
if (text)
|
|
516
|
-
await channel.
|
|
1452
|
+
await channel.sendStreamText(channelId, text, false, envelope.replyContext);
|
|
517
1453
|
return;
|
|
518
1454
|
}
|
|
1455
|
+
case 'status.completed':
|
|
1456
|
+
await channel.finishStream(envelope.replyContext);
|
|
1457
|
+
return;
|
|
1458
|
+
case 'status.interrupted':
|
|
1459
|
+
await channel.finishStream(envelope.replyContext, payload.metadata?.reason === 'daemon_restart' ? '服务正在重启,将继续处理…' : '任务已中止');
|
|
1460
|
+
return;
|
|
1461
|
+
case 'status.timeout':
|
|
1462
|
+
await channel.finishStream(envelope.replyContext, '任务已中止');
|
|
1463
|
+
return;
|
|
1464
|
+
case 'status.started':
|
|
1465
|
+
case 'status.queued':
|
|
1466
|
+
case 'status.progress': return;
|
|
519
1467
|
case 'status.requires_action':
|
|
520
|
-
await channel.sendMessage(channelId, '等待 owner 审批');
|
|
1468
|
+
await channel.sendMessage(channelId, '等待 owner 审批', envelope.replyContext);
|
|
521
1469
|
return;
|
|
522
1470
|
case 'status.error':
|
|
523
|
-
if (payload.metadata?.message)
|
|
524
|
-
await channel.sendMessage(channelId, payload.metadata.message);
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
1471
|
+
if (payload.metadata?.message) {
|
|
1472
|
+
await channel.sendMessage(channelId, payload.metadata.message, envelope.replyContext);
|
|
1473
|
+
}
|
|
1474
|
+
else {
|
|
1475
|
+
await channel.finishStream(envelope.replyContext, '任务执行失败');
|
|
1476
|
+
}
|
|
529
1477
|
return;
|
|
1478
|
+
case 'interaction': {
|
|
1479
|
+
try {
|
|
1480
|
+
const taskId = await channel.sendInteraction(channelId, payload.interaction, envelope.replyContext);
|
|
1481
|
+
if (taskId)
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
catch (error) {
|
|
1485
|
+
logger.warn(`[WeCom] Interaction card delivery failed, using text fallback: ${error instanceof Error ? error.message : String(error)}`);
|
|
1486
|
+
}
|
|
1487
|
+
if (payload.fallbackText) {
|
|
1488
|
+
await channel.sendMessage(channelId, payload.fallbackText, envelope.replyContext);
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
throw new Error('WeCom interaction delivery failed and no fallback text was provided');
|
|
1492
|
+
}
|
|
530
1493
|
default: return;
|
|
531
1494
|
}
|
|
532
1495
|
},
|
|
1496
|
+
onInteraction: (callback) => channel.onInteraction(callback),
|
|
1497
|
+
onInteractionInvalidated: (callback) => channel.onInteractionInvalidated(callback),
|
|
1498
|
+
invalidateInteraction: (interactionId, reason) => channel.invalidateInteraction(interactionId, reason),
|
|
533
1499
|
};
|
|
534
1500
|
const policy = {
|
|
535
1501
|
canSwitchProject: (_, identity) => identity === 'owner' || identity === 'admin',
|
|
@@ -546,19 +1512,21 @@ export class WecomChannelPlugin {
|
|
|
546
1512
|
return {
|
|
547
1513
|
channelType: 'wecom', adapter, channel,
|
|
548
1514
|
policy,
|
|
549
|
-
options: { fileMarkerPattern:
|
|
1515
|
+
options: { fileMarkerPattern: createSendFileMarkerPattern(), supportsImages: true, flushDelay: inst.flushDelay },
|
|
550
1516
|
connect: () => channel.connect(),
|
|
551
1517
|
disconnect: () => channel.disconnect(),
|
|
552
1518
|
onProjectPathRequest: () => Promise.resolve(ctx.defaultProjectPath),
|
|
553
1519
|
registerBridge(bridge, channelType) {
|
|
554
1520
|
bridge.register(adapter.channelName, (handler) => channel.onMessage(async (event) => {
|
|
555
|
-
handler({
|
|
1521
|
+
await handler({
|
|
556
1522
|
channel: adapter.channelName, channelType, channelId: event.channelId,
|
|
557
1523
|
selfAID: ctx.agentName, content: event.content, images: event.images,
|
|
558
1524
|
chatType: event.chatType || 'private', peerId: event.peerId || '',
|
|
559
1525
|
peerName: event.peerName, messageId: event.messageId,
|
|
1526
|
+
replyContext: event.replyContext,
|
|
1527
|
+
source: event.source,
|
|
560
1528
|
});
|
|
561
|
-
}), (channelId, text) => channel.sendMessage(channelId, text), adapter, channelType);
|
|
1529
|
+
}), (channelId, text, replyContext) => channel.sendMessage(channelId, text, replyContext), adapter, channelType);
|
|
562
1530
|
},
|
|
563
1531
|
};
|
|
564
1532
|
}
|