evolcore 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -793
- package/dist/agents/claude-runner.js +197 -17
- package/dist/agents/codex-runner.js +46 -3
- package/dist/aun/outbox.js +8 -0
- package/dist/channels/aun.js +21 -4
- package/dist/channels/contact-bind-code.js +134 -0
- package/dist/channels/dingtalk.js +979 -149
- package/dist/channels/feishu.js +130 -54
- package/dist/channels/wecom-card.js +101 -0
- package/dist/channels/wecom-onboarding.js +82 -0
- package/dist/channels/wecom-state.js +191 -0
- package/dist/channels/wecom.js +755 -163
- package/dist/cli/agent-command.js +2 -1
- package/dist/cli/aun-commands.js +88 -33
- package/dist/cli/bench.js +2 -2
- package/dist/cli/contact.js +71 -0
- package/dist/cli/ctl-command.js +2 -2
- package/dist/cli/daemon-commands.js +77 -214
- package/dist/cli/handoff-command.js +2 -2
- package/dist/cli/help.js +9 -5
- package/dist/cli/index.js +132 -116
- package/dist/cli/init-channel.js +92 -97
- package/dist/cli/init.js +63 -26
- package/dist/cli/model.js +2 -1
- package/dist/cli/net-check.js +2 -2
- package/dist/cli/queue-command.js +30 -6
- package/dist/cli/raw-key-input.js +25 -0
- package/dist/cli/response.js +5 -6
- package/dist/cli/restart-monitor.js +25 -1
- package/dist/cli/stats.js +6 -4
- package/dist/cli/trigger-command.js +55 -15
- package/dist/cli/version.js +6 -1
- package/dist/config/builtin-role-templates.js +22 -10
- package/dist/config/builtin-roles.js +7 -1
- package/dist/config/config-manager.js +221 -17
- package/dist/config/contact-alias.js +68 -0
- package/dist/config/contact-book-store.js +454 -0
- package/dist/config/contact-book-v2-startup.js +35 -0
- package/dist/config/contact-book.js +156 -303
- package/dist/config/contact-operation-service.js +110 -0
- package/dist/config/peer-role-resolver.js +133 -54
- package/dist/config/role-ranks.js +18 -0
- package/dist/config/role-service.js +16 -19
- package/dist/config/role-store.js +16 -5
- package/dist/config/roles.js +10 -1
- package/dist/config-store.js +0 -2
- package/dist/core/auth/authorization-audit.js +10 -1
- package/dist/core/auth/operation-authorizer.js +2 -0
- package/dist/core/auth/operation-catalog.js +56 -0
- package/dist/core/command/command-handler.js +105 -10
- package/dist/core/command/connect-menu.js +374 -0
- package/dist/core/command/menu-handler.js +114 -16
- package/dist/core/command/role-menu.js +128 -29
- package/dist/core/command/slash-handler.js +28 -18
- package/dist/core/daemon-file-cache.js +12 -6
- package/dist/core/event-catalog.js +70 -0
- package/dist/core/evolagent-registry.js +0 -1
- package/dist/core/evolagent.js +20 -9
- package/dist/core/message/im-renderer.js +2 -0
- package/dist/core/message/message-bridge.js +79 -8
- package/dist/core/message/message-queue.js +10 -0
- package/dist/core/message/message-utils.js +8 -2
- package/dist/core/message/response-engine.js +269 -25
- package/dist/core/message/send-receipt.js +24 -0
- package/dist/core/message/stream-debouncer.js +11 -2
- package/dist/core/permission/tool-policy.js +47 -15
- package/dist/core/protected-paths.js +2 -0
- package/dist/core/session/session-fs-store.js +44 -3
- package/dist/core/session/session-manager.js +61 -5
- package/dist/index.js +62 -6
- package/dist/ipc.js +34 -5
- package/dist/stats/billing.js +20 -8
- package/dist/trigger/manager.js +3 -0
- package/dist/trigger/parser.js +77 -2
- package/dist/trigger/patch.js +8 -1
- package/dist/trigger/scheduler.js +3 -1
- package/dist/trigger/validation.js +13 -0
- package/dist/utils/aid-bind.js +43 -29
- package/dist/utils/instance-registry.js +14 -7
- package/dist/utils/log-writer.js +46 -0
- package/dist/utils/media-cache.js +4 -1
- package/dist/utils/model-prices.jsonl +6 -3
- package/dist/utils/restart-safety.js +31 -0
- package/dist/utils/system-memory.js +62 -0
- package/kits/docs/INDEX.md +2 -1
- package/kits/docs/evolcore/INDEX.md +5 -3
- package/kits/docs/evolcore/agent.md +9 -1
- package/kits/docs/evolcore/aid.md +5 -2
- package/kits/docs/evolcore/contact.md +57 -0
- package/kits/docs/evolcore/fs.md +9 -0
- package/kits/docs/evolcore/group.md +12 -3
- package/kits/docs/evolcore/model.md +4 -1
- package/kits/docs/evolcore/msg.md +9 -3
- package/kits/docs/evolcore/response.md +16 -21
- package/kits/docs/evolcore/rpc.md +2 -0
- package/kits/docs/evolcore/stats.md +15 -2
- package/kits/docs/evolcore/storage.md +1 -0
- package/kits/docs/evolcore/trigger.md +17 -2
- package/kits/eck_manifest.json +12 -0
- package/kits/migrations/migrate-contact-book-v2.mjs +747 -0
- package/kits/schemas/_meta.json +7 -4
- package/kits/schemas/agent-config.schema.6.json +322 -0
- package/kits/schemas/contact-book.schema.2.json +43 -0
- package/kits/schemas/relation-config.schema.5.json +47 -0
- package/kits/schemas/role-config.schema.1.json +1 -0
- package/kits/schemas/role-registry.schema.1.json +2 -2
- package/kits/templates/roles/admin.json +1 -0
- package/kits/templates/roles/member.json +1 -0
- package/kits/templates/roles/owner.json +1 -0
- package/kits/templates/roles/visitor.json +1 -0
- package/kits/templates/system-fragments/commands.md +3 -1
- package/package.json +4 -4
- package/assets/brand/evolcore/README.md +0 -19
- package/assets/brand/evolcore/evolcore-app-icon.png +0 -0
- package/assets/brand/evolcore/evolcore-app-icon.svg +0 -13
- package/assets/brand/evolcore/evolcore-brand-board.png +0 -0
- package/assets/brand/evolcore/evolcore-brand-board.svg +0 -126
- package/assets/brand/evolcore/evolcore-logo-kit.zip +0 -0
- package/assets/brand/evolcore/evolcore-logo-reverse.png +0 -0
- package/assets/brand/evolcore/evolcore-logo-reverse.svg +0 -14
- package/assets/brand/evolcore/evolcore-logo.png +0 -0
- package/assets/brand/evolcore/evolcore-logo.svg +0 -14
- package/assets/brand/evolcore/evolcore-mark.png +0 -0
- package/assets/brand/evolcore/evolcore-mark.svg +0 -10
package/dist/channels/wecom.js
CHANGED
|
@@ -4,18 +4,123 @@ import { requireOptional } from '../utils/npm-ops.js';
|
|
|
4
4
|
import { middleOutputModePolicy, resolveShowActivities, showActivitiesPolicy } from '../core/channel-loader.js';
|
|
5
5
|
import { formatItemsAsText } from '../core/message/items-formatter.js';
|
|
6
6
|
import { initWelcomeManager, sendWelcomeIfNeeded } from '../utils/welcome.js';
|
|
7
|
+
import { bufferToInboundImage, sanitizeFileName, saveToUploads, validateUrl } from '../utils/media-cache.js';
|
|
8
|
+
import { PersistentWecomCardStore, PersistentWecomDeduper, createWecomCardTaskId, wecomChannelDataPaths, } from './wecom-state.js';
|
|
9
|
+
import { buildWecomInteractionCard, buildWecomTerminalCard, buildWecomUnknownCard, parseWecomCardResponse, } from './wecom-card.js';
|
|
10
|
+
import { ContactBindCodeRegistry, } from './contact-bind-code.js';
|
|
11
|
+
function wecomErrorCode(error) {
|
|
12
|
+
if (!error || typeof error !== 'object')
|
|
13
|
+
return undefined;
|
|
14
|
+
const direct = error.errcode;
|
|
15
|
+
if (typeof direct === 'number')
|
|
16
|
+
return direct;
|
|
17
|
+
const responseCode = error.response?.data?.errcode;
|
|
18
|
+
return typeof responseCode === 'number' ? responseCode : undefined;
|
|
19
|
+
}
|
|
20
|
+
function isWecomAuthError(error) {
|
|
21
|
+
return error.code === 'WS_AUTH_FAILURE_EXHAUSTED'
|
|
22
|
+
|| /^Authentication failed:/i.test(error.message);
|
|
23
|
+
}
|
|
24
|
+
function sanitizeWecomSdkLog(message) {
|
|
25
|
+
return message
|
|
26
|
+
.replace(/("(?:aeskey|secret)"\s*:\s*")[^"]*(")/gi, '$1<redacted>$2')
|
|
27
|
+
.replace(/("url"\s*:\s*")[^"]*(")/gi, '$1<redacted>$2')
|
|
28
|
+
.replace(/(url=)https?:\/\/\S+/gi, '$1<redacted>');
|
|
29
|
+
}
|
|
30
|
+
function validateWecomMediaUrl(url) {
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = new URL(url);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error('WeCom media URL is invalid');
|
|
37
|
+
}
|
|
38
|
+
if (parsed.protocol !== 'https:')
|
|
39
|
+
throw new Error(`WeCom media URL must use HTTPS: ${parsed.protocol}`);
|
|
40
|
+
if (parsed.username || parsed.password)
|
|
41
|
+
throw new Error('WeCom media URL must not contain credentials');
|
|
42
|
+
const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
43
|
+
if (host === 'localhost'
|
|
44
|
+
|| host.endsWith('.localhost')
|
|
45
|
+
|| host.endsWith('.local')
|
|
46
|
+
|| host === '::1'
|
|
47
|
+
|| /^(?:fc|fd|fe8|fe9|fea|feb)/i.test(host)) {
|
|
48
|
+
throw new Error(`WeCom media URL rejected: local host ${host}`);
|
|
49
|
+
}
|
|
50
|
+
const validation = validateUrl(url, { allowedHosts: new Set() });
|
|
51
|
+
if (!validation.ok)
|
|
52
|
+
throw new Error(`WeCom media URL rejected: ${validation.reason}`);
|
|
53
|
+
}
|
|
54
|
+
function truncateUtf8Bytes(value, maxBytes) {
|
|
55
|
+
const input = Buffer.from(value, 'utf8');
|
|
56
|
+
if (input.length <= maxBytes)
|
|
57
|
+
return value;
|
|
58
|
+
let end = maxBytes;
|
|
59
|
+
while (end > 0 && (input[end] & 0xc0) === 0x80)
|
|
60
|
+
end--;
|
|
61
|
+
return input.subarray(0, end).toString('utf8');
|
|
62
|
+
}
|
|
63
|
+
function splitUtf8Bytes(value, maxBytes) {
|
|
64
|
+
if (!value)
|
|
65
|
+
return [];
|
|
66
|
+
const chunks = [];
|
|
67
|
+
let remaining = value;
|
|
68
|
+
while (Buffer.byteLength(remaining, 'utf8') > maxBytes) {
|
|
69
|
+
let chunk = truncateUtf8Bytes(remaining, maxBytes);
|
|
70
|
+
const paragraphBreak = chunk.lastIndexOf('\n\n');
|
|
71
|
+
const lineBreak = chunk.lastIndexOf('\n');
|
|
72
|
+
const splitAt = paragraphBreak > Math.floor(chunk.length / 2)
|
|
73
|
+
? paragraphBreak
|
|
74
|
+
: lineBreak > Math.floor(chunk.length / 2)
|
|
75
|
+
? lineBreak
|
|
76
|
+
: chunk.length;
|
|
77
|
+
chunk = chunk.slice(0, splitAt);
|
|
78
|
+
if (!chunk)
|
|
79
|
+
chunk = truncateUtf8Bytes(remaining, maxBytes);
|
|
80
|
+
chunks.push(chunk);
|
|
81
|
+
remaining = remaining.slice(chunk.length);
|
|
82
|
+
}
|
|
83
|
+
if (remaining)
|
|
84
|
+
chunks.push(remaining);
|
|
85
|
+
return chunks;
|
|
86
|
+
}
|
|
87
|
+
const wecomContactBinds = new ContactBindCodeRegistry({
|
|
88
|
+
channelType: 'wecom',
|
|
89
|
+
displayName: '企微',
|
|
90
|
+
initCommand: 'ec init wecom',
|
|
91
|
+
});
|
|
92
|
+
export function registerPendingWecomContactBind(req) {
|
|
93
|
+
return wecomContactBinds.register(req);
|
|
94
|
+
}
|
|
95
|
+
export function handlePendingWecomContactBindMessage(ctx) {
|
|
96
|
+
return wecomContactBinds.handle(ctx);
|
|
97
|
+
}
|
|
98
|
+
export function getPendingWecomContactBind(selfAid, channelName) {
|
|
99
|
+
return wecomContactBinds.get(selfAid, channelName);
|
|
100
|
+
}
|
|
101
|
+
export function clearPendingWecomContactBinds() {
|
|
102
|
+
wecomContactBinds.clear();
|
|
103
|
+
}
|
|
7
104
|
// ── WecomChannel ───────────────────────────────────────────────────────────────
|
|
8
105
|
export class WecomChannel {
|
|
9
106
|
agentAid;
|
|
10
107
|
channelName;
|
|
11
108
|
config;
|
|
12
109
|
client = null;
|
|
13
|
-
|
|
110
|
+
connectionState = 'idle';
|
|
111
|
+
lastConnectionError;
|
|
14
112
|
messageHandler = null;
|
|
15
113
|
seenMessages = new Map();
|
|
114
|
+
persistentDeduper;
|
|
115
|
+
cardStore;
|
|
16
116
|
cleanupInterval = null;
|
|
17
117
|
projectPathProvider = null;
|
|
18
|
-
|
|
118
|
+
interactionCallback;
|
|
119
|
+
interactionInvalidationCallback;
|
|
120
|
+
channelKey;
|
|
121
|
+
cardCallbacksInFlight = new Set();
|
|
122
|
+
interactionSendTails = new Map();
|
|
123
|
+
connectPromise;
|
|
19
124
|
activeStreams = new Map();
|
|
20
125
|
// Welcome message manager
|
|
21
126
|
welcomeManager;
|
|
@@ -23,6 +128,13 @@ export class WecomChannel {
|
|
|
23
128
|
this.agentAid = agentAid;
|
|
24
129
|
this.channelName = channelName;
|
|
25
130
|
this.config = config;
|
|
131
|
+
this.channelKey = channelName || `wecom#${agentAid || 'standalone'}#main`;
|
|
132
|
+
this.cardStore = new PersistentWecomCardStore(undefined, this.channelKey);
|
|
133
|
+
if (agentAid && channelName) {
|
|
134
|
+
const paths = wecomChannelDataPaths(agentAid, channelName);
|
|
135
|
+
this.persistentDeduper = new PersistentWecomDeduper(paths.dedupFile, channelName);
|
|
136
|
+
this.cardStore = new PersistentWecomCardStore(paths.cardFile, channelName);
|
|
137
|
+
}
|
|
26
138
|
// 初始化 welcomeManager(使用共享帮助函数)
|
|
27
139
|
if (agentAid && channelName) {
|
|
28
140
|
this.welcomeManager = initWelcomeManager('wecom', agentAid, channelName);
|
|
@@ -30,51 +142,143 @@ export class WecomChannel {
|
|
|
30
142
|
}
|
|
31
143
|
// ── Public helpers (testable) ─────────────────────────────────────────────
|
|
32
144
|
isDuplicate(msgId) {
|
|
145
|
+
if (this.persistentDeduper)
|
|
146
|
+
return this.persistentDeduper.checkAndMark(msgId);
|
|
33
147
|
if (this.seenMessages.has(msgId))
|
|
34
148
|
return true;
|
|
35
149
|
this.seenMessages.set(msgId, Date.now());
|
|
36
150
|
return false;
|
|
37
151
|
}
|
|
38
152
|
resolveChatId(chattype, chatid, userid) {
|
|
39
|
-
return chattype === 'group'
|
|
153
|
+
return chattype === 'group' ? (chatid || '') : userid;
|
|
40
154
|
}
|
|
41
155
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
42
156
|
async connect() {
|
|
157
|
+
if (this.connectionState === 'authenticated' && this.client)
|
|
158
|
+
return;
|
|
159
|
+
if (this.connectPromise)
|
|
160
|
+
return this.connectPromise;
|
|
161
|
+
if (this.client)
|
|
162
|
+
return this.waitForAuthentication(this.client, 20_000);
|
|
163
|
+
const pending = this.connectOnce();
|
|
164
|
+
this.connectPromise = pending;
|
|
165
|
+
try {
|
|
166
|
+
await pending;
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
if (this.connectPromise === pending)
|
|
170
|
+
this.connectPromise = undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async connectOnce() {
|
|
43
174
|
const { botId, secret } = this.config;
|
|
44
175
|
if (!botId || !secret) {
|
|
45
176
|
throw new Error('WeCom botId/secret not configured');
|
|
46
177
|
}
|
|
47
|
-
const
|
|
48
|
-
this.
|
|
178
|
+
const sdk = await requireOptional('@wecom/aibot-node-sdk');
|
|
179
|
+
this.connectionState = 'connecting';
|
|
180
|
+
this.lastConnectionError = undefined;
|
|
181
|
+
const client = new sdk.WSClient({
|
|
182
|
+
botId,
|
|
183
|
+
secret,
|
|
184
|
+
heartbeatInterval: 30_000,
|
|
185
|
+
maxReconnectAttempts: 10,
|
|
186
|
+
maxAuthFailureAttempts: 5,
|
|
187
|
+
requestTimeout: 15_000,
|
|
188
|
+
logger: {
|
|
189
|
+
debug: (message) => logger.debug(`[WeCom SDK] ${sanitizeWecomSdkLog(message)}`),
|
|
190
|
+
info: (message, ...args) => logger.info(`[WeCom SDK] ${sanitizeWecomSdkLog(message)}`, ...args),
|
|
191
|
+
warn: (message, ...args) => logger.warn(`[WeCom SDK] ${sanitizeWecomSdkLog(message)}`, ...args),
|
|
192
|
+
error: (message, ...args) => logger.error(`[WeCom SDK] ${sanitizeWecomSdkLog(message)}`, ...args),
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
this.client = client;
|
|
49
196
|
// Message events
|
|
50
|
-
|
|
197
|
+
client.on('message', (frame) => {
|
|
198
|
+
if (this.client !== client)
|
|
199
|
+
return;
|
|
51
200
|
this.handleIncoming(frame).catch((err) => {
|
|
52
201
|
logger.error('[WeCom] Failed to process incoming message:', err);
|
|
53
202
|
});
|
|
54
203
|
});
|
|
55
204
|
// Event callbacks (enter_chat, etc.)
|
|
56
|
-
|
|
205
|
+
client.on('event.enter_chat', (frame) => {
|
|
206
|
+
if (this.client !== client)
|
|
207
|
+
return;
|
|
57
208
|
const body = frame?.body;
|
|
58
209
|
if (body) {
|
|
59
210
|
logger.debug(`[WeCom] User entered chat: userid=${body.from?.userid} chattype=${body.chattype}`);
|
|
60
211
|
}
|
|
61
212
|
});
|
|
62
213
|
// Lifecycle events
|
|
63
|
-
|
|
214
|
+
client.on('authenticated', () => {
|
|
215
|
+
if (this.client !== client)
|
|
216
|
+
return;
|
|
217
|
+
this.connectionState = 'authenticated';
|
|
218
|
+
this.lastConnectionError = undefined;
|
|
64
219
|
logger.info('[WeCom] WebSocket authenticated');
|
|
65
220
|
});
|
|
66
|
-
|
|
221
|
+
client.on('disconnected', (reason) => {
|
|
222
|
+
if (this.client !== client)
|
|
223
|
+
return;
|
|
67
224
|
logger.warn(`[WeCom] WebSocket disconnected: ${reason}`);
|
|
68
|
-
this.
|
|
225
|
+
if (this.connectionState !== 'kicked')
|
|
226
|
+
this.connectionState = 'disconnected';
|
|
227
|
+
this.lastConnectionError = reason;
|
|
228
|
+
this.expireActiveStreams();
|
|
69
229
|
});
|
|
70
|
-
|
|
230
|
+
client.on('reconnecting', (attempt) => {
|
|
231
|
+
if (this.client !== client)
|
|
232
|
+
return;
|
|
233
|
+
this.connectionState = 'reconnecting';
|
|
234
|
+
this.expireActiveStreams();
|
|
71
235
|
logger.info(`[WeCom] Reconnecting (attempt ${attempt})...`);
|
|
72
236
|
});
|
|
73
|
-
|
|
237
|
+
client.on('error', (error) => {
|
|
238
|
+
if (this.client !== client)
|
|
239
|
+
return;
|
|
240
|
+
this.lastConnectionError = error.message;
|
|
241
|
+
if (isWecomAuthError(error))
|
|
242
|
+
this.connectionState = 'auth_failed';
|
|
243
|
+
else if (error.code === 'WS_RECONNECT_EXHAUSTED')
|
|
244
|
+
this.connectionState = 'disconnected';
|
|
74
245
|
logger.error('[WeCom] WebSocket error:', error);
|
|
75
246
|
});
|
|
76
|
-
|
|
77
|
-
|
|
247
|
+
client.on('event.template_card_event', (frame) => {
|
|
248
|
+
if (this.client !== client)
|
|
249
|
+
return;
|
|
250
|
+
this.handleTemplateCardEvent(frame).catch(error => {
|
|
251
|
+
logger.error('[WeCom] Failed to process template card event:', error);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
client.on('event.disconnected_event', () => {
|
|
255
|
+
if (this.client !== client)
|
|
256
|
+
return;
|
|
257
|
+
this.connectionState = 'kicked';
|
|
258
|
+
this.lastConnectionError = 'A newer connection subscribed with the same Bot ID';
|
|
259
|
+
this.expireActiveStreams();
|
|
260
|
+
logger.error(`[WeCom] Connection kicked for channel ${this.channelKey}; another client is using this Bot ID`);
|
|
261
|
+
});
|
|
262
|
+
client.connect();
|
|
263
|
+
try {
|
|
264
|
+
await this.waitForAuthentication(client, 20_000);
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
const wasCurrentClient = this.client === client;
|
|
268
|
+
if (wasCurrentClient)
|
|
269
|
+
this.client = null;
|
|
270
|
+
try {
|
|
271
|
+
client.disconnect();
|
|
272
|
+
}
|
|
273
|
+
catch { /* ignore */ }
|
|
274
|
+
if (wasCurrentClient) {
|
|
275
|
+
const failedState = this.connectionState;
|
|
276
|
+
if (failedState !== 'auth_failed')
|
|
277
|
+
this.connectionState = 'disconnected';
|
|
278
|
+
this.lastConnectionError = error instanceof Error ? error.message : String(error);
|
|
279
|
+
}
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
78
282
|
// Hourly cleanup of old dedup entries
|
|
79
283
|
this.cleanupInterval = setInterval(() => {
|
|
80
284
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
@@ -82,29 +286,39 @@ export class WecomChannel {
|
|
|
82
286
|
if (ts < cutoff)
|
|
83
287
|
this.seenMessages.delete(id);
|
|
84
288
|
}
|
|
289
|
+
this.cleanupStreams();
|
|
85
290
|
}, 60 * 60 * 1000);
|
|
86
291
|
logger.info('[WeCom] Channel connected');
|
|
87
292
|
}
|
|
88
293
|
async disconnect() {
|
|
89
|
-
this.
|
|
294
|
+
this.connectionState = 'idle';
|
|
90
295
|
if (this.cleanupInterval) {
|
|
91
296
|
clearInterval(this.cleanupInterval);
|
|
92
297
|
this.cleanupInterval = null;
|
|
93
298
|
}
|
|
94
|
-
|
|
299
|
+
const client = this.client;
|
|
300
|
+
this.client = null;
|
|
301
|
+
if (client) {
|
|
95
302
|
try {
|
|
96
|
-
|
|
303
|
+
client.disconnect();
|
|
97
304
|
}
|
|
98
305
|
catch { /* ignore */ }
|
|
99
|
-
this.client = null;
|
|
100
306
|
}
|
|
307
|
+
this.activeStreams.clear();
|
|
101
308
|
logger.info('[WeCom] Channel disconnected');
|
|
102
309
|
}
|
|
103
310
|
onMessage(handler) {
|
|
104
311
|
this.messageHandler = handler;
|
|
105
312
|
}
|
|
313
|
+
onProjectPathRequest(provider) {
|
|
314
|
+
this.projectPathProvider = provider;
|
|
315
|
+
}
|
|
106
316
|
getStatus() {
|
|
107
|
-
return {
|
|
317
|
+
return {
|
|
318
|
+
connected: this.connectionState === 'authenticated',
|
|
319
|
+
state: this.connectionState,
|
|
320
|
+
error: this.lastConnectionError,
|
|
321
|
+
};
|
|
108
322
|
}
|
|
109
323
|
async reconnect() {
|
|
110
324
|
await this.disconnect();
|
|
@@ -116,11 +330,144 @@ export class WecomChannel {
|
|
|
116
330
|
return `重连失败: ${err instanceof Error ? err.message : String(err)}`;
|
|
117
331
|
}
|
|
118
332
|
}
|
|
333
|
+
onInteraction(callback) {
|
|
334
|
+
this.interactionCallback = callback;
|
|
335
|
+
}
|
|
336
|
+
onInteractionInvalidated(callback) {
|
|
337
|
+
this.interactionInvalidationCallback = callback;
|
|
338
|
+
}
|
|
339
|
+
invalidateInteraction(interactionId, reason = 'cancelled') {
|
|
340
|
+
this.cardStore?.invalidateByInteractionId(interactionId, reason);
|
|
341
|
+
}
|
|
342
|
+
async withInteractionSendLock(chatId, run) {
|
|
343
|
+
const previous = this.interactionSendTails.get(chatId) ?? Promise.resolve();
|
|
344
|
+
let release;
|
|
345
|
+
const current = new Promise(resolve => { release = resolve; });
|
|
346
|
+
this.interactionSendTails.set(chatId, current);
|
|
347
|
+
await previous.catch(() => undefined);
|
|
348
|
+
try {
|
|
349
|
+
return await run();
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
release();
|
|
353
|
+
if (this.interactionSendTails.get(chatId) === current)
|
|
354
|
+
this.interactionSendTails.delete(chatId);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async invalidateSupersededCards(records) {
|
|
358
|
+
for (const record of records) {
|
|
359
|
+
if (this.interactionInvalidationCallback) {
|
|
360
|
+
try {
|
|
361
|
+
await this.interactionInvalidationCallback(record.interaction.id, 'superseded');
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
logger.warn(`[WeCom] Failed to cancel superseded interaction ${record.interaction.id}: ${String(error)}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
this.cardStore?.invalidateByInteractionId(record.interaction.id, 'superseded');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
async waitForAuthentication(client, timeoutMs) {
|
|
371
|
+
if (this.connectionState === 'authenticated')
|
|
372
|
+
return;
|
|
373
|
+
await new Promise((resolve, reject) => {
|
|
374
|
+
let settled = false;
|
|
375
|
+
const finish = (error) => {
|
|
376
|
+
if (settled)
|
|
377
|
+
return;
|
|
378
|
+
settled = true;
|
|
379
|
+
clearTimeout(timer);
|
|
380
|
+
client.off('authenticated', onAuthenticated);
|
|
381
|
+
client.off('error', onError);
|
|
382
|
+
client.off('disconnected', onDisconnected);
|
|
383
|
+
if (error)
|
|
384
|
+
reject(error);
|
|
385
|
+
else
|
|
386
|
+
resolve();
|
|
387
|
+
};
|
|
388
|
+
const onAuthenticated = () => finish();
|
|
389
|
+
const onError = (error) => {
|
|
390
|
+
if (isWecomAuthError(error))
|
|
391
|
+
finish(error);
|
|
392
|
+
};
|
|
393
|
+
const onDisconnected = (reason) => finish(new Error(`WeCom disconnected before authentication: ${reason}`));
|
|
394
|
+
const timer = setTimeout(() => {
|
|
395
|
+
finish(new Error(`WeCom authentication timed out after ${timeoutMs}ms`));
|
|
396
|
+
}, timeoutMs);
|
|
397
|
+
client.on('authenticated', onAuthenticated);
|
|
398
|
+
client.on('error', onError);
|
|
399
|
+
client.on('disconnected', onDisconnected);
|
|
400
|
+
if (this.connectionState === 'authenticated')
|
|
401
|
+
finish();
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
cleanupStreams(now = Date.now()) {
|
|
405
|
+
const cutoff = now - 30 * 60 * 1000;
|
|
406
|
+
for (const [reqId, stream] of this.activeStreams) {
|
|
407
|
+
if (stream.finished || stream.lastUpdatedAt < cutoff)
|
|
408
|
+
this.activeStreams.delete(reqId);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
expireActiveStreams() {
|
|
412
|
+
for (const stream of this.activeStreams.values())
|
|
413
|
+
stream.expired = true;
|
|
414
|
+
}
|
|
415
|
+
createReplyContext(frame, chatId, messageId) {
|
|
416
|
+
const reqId = String(frame.headers?.req_id ?? '').trim();
|
|
417
|
+
const streamId = crypto.randomUUID();
|
|
418
|
+
if (reqId) {
|
|
419
|
+
this.activeStreams.set(reqId, {
|
|
420
|
+
reqId,
|
|
421
|
+
streamId,
|
|
422
|
+
frame: { headers: { ...frame.headers, req_id: reqId } },
|
|
423
|
+
chatId,
|
|
424
|
+
messageId,
|
|
425
|
+
accumulatedText: '',
|
|
426
|
+
createdAt: Date.now(),
|
|
427
|
+
lastUpdatedAt: Date.now(),
|
|
428
|
+
started: false,
|
|
429
|
+
finished: false,
|
|
430
|
+
expired: false,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
replyToMessageId: messageId,
|
|
435
|
+
metadata: {
|
|
436
|
+
wecomReqId: reqId,
|
|
437
|
+
wecomStreamId: streamId,
|
|
438
|
+
wecomMessageId: messageId,
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
streamFromReplyContext(replyContext) {
|
|
443
|
+
const reqId = replyContext?.metadata?.wecomReqId;
|
|
444
|
+
if (typeof reqId !== 'string' || !reqId)
|
|
445
|
+
return undefined;
|
|
446
|
+
return this.activeStreams.get(reqId);
|
|
447
|
+
}
|
|
448
|
+
requireClient() {
|
|
449
|
+
if (!this.client)
|
|
450
|
+
throw new Error('WeCom client is not connected');
|
|
451
|
+
return this.client;
|
|
452
|
+
}
|
|
453
|
+
async sendProactiveMarkdown(chatId, content) {
|
|
454
|
+
const client = this.requireClient();
|
|
455
|
+
for (const chunk of splitUtf8Bytes(content, 20_480)) {
|
|
456
|
+
await client.sendMessage(chatId, {
|
|
457
|
+
msgtype: 'markdown',
|
|
458
|
+
markdown: { content: chunk },
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
119
462
|
// ── Inbound message handling ──────────────────────────────────────────────
|
|
120
463
|
async handleIncoming(frame) {
|
|
121
464
|
const body = frame?.body;
|
|
122
465
|
if (!body)
|
|
123
466
|
return;
|
|
467
|
+
if (body.aibotid && body.aibotid !== this.config.botId) {
|
|
468
|
+
logger.warn(`[WeCom] Ignoring callback for unexpected Bot ID: ${body.aibotid}`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
124
471
|
const msgId = body.msgid;
|
|
125
472
|
const chattype = body.chattype || 'single';
|
|
126
473
|
const chatid = body.chatid;
|
|
@@ -132,12 +479,12 @@ export class WecomChannel {
|
|
|
132
479
|
return;
|
|
133
480
|
}
|
|
134
481
|
const channelId = this.resolveChatId(chattype, chatid, userid);
|
|
482
|
+
if (!channelId) {
|
|
483
|
+
logger.warn(`[WeCom] Ignoring ${chattype} message without a routable ${chattype === 'group' ? 'chatid' : 'userid'}`);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
135
486
|
const chatTypeNorm = chattype === 'group' ? 'group' : 'private';
|
|
136
|
-
|
|
137
|
-
this.activeStreams.set(channelId, {
|
|
138
|
-
streamId: crypto.randomUUID(),
|
|
139
|
-
frame: { headers: frame.headers },
|
|
140
|
-
});
|
|
487
|
+
const replyContext = this.createReplyContext(frame, channelId, msgId);
|
|
141
488
|
if (!this.messageHandler)
|
|
142
489
|
return;
|
|
143
490
|
// 首次交互欢迎消息(使用共享帮助函数)
|
|
@@ -146,85 +493,143 @@ export class WecomChannel {
|
|
|
146
493
|
const text = body.text?.content?.trim();
|
|
147
494
|
if (!text)
|
|
148
495
|
return;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (body.quote) {
|
|
152
|
-
const quoteText = body.quote.text?.content || '';
|
|
153
|
-
if (quoteText) {
|
|
154
|
-
content = `[引用: ${quoteText}]\n${text}`;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
496
|
+
const quote = await this.processQuote(body.quote, channelId);
|
|
497
|
+
const content = quote.prefix ? `${quote.prefix}\n${text}` : text;
|
|
157
498
|
await this.messageHandler({
|
|
158
499
|
channelId, content, chatType: chatTypeNorm,
|
|
159
500
|
peerId: userid, messageId: msgId,
|
|
501
|
+
images: quote.images.length > 0 ? quote.images : undefined,
|
|
502
|
+
replyContext,
|
|
160
503
|
});
|
|
161
504
|
}
|
|
162
505
|
else if (msgtype === 'image') {
|
|
163
|
-
await this.handleImageMessage(body, channelId, chatTypeNorm, userid, msgId,
|
|
506
|
+
await this.handleImageMessage(body, channelId, chatTypeNorm, userid, msgId, replyContext);
|
|
164
507
|
}
|
|
165
508
|
else if (msgtype === 'voice') {
|
|
166
509
|
const voiceText = body.voice?.content?.trim();
|
|
167
510
|
if (voiceText) {
|
|
168
511
|
await this.messageHandler({
|
|
169
512
|
channelId, content: voiceText, chatType: chatTypeNorm,
|
|
170
|
-
peerId: userid, messageId: msgId,
|
|
513
|
+
peerId: userid, messageId: msgId, replyContext,
|
|
171
514
|
});
|
|
172
515
|
}
|
|
173
516
|
}
|
|
174
517
|
else if (msgtype === 'file') {
|
|
175
|
-
await this.handleFileMessage(body, channelId, chatTypeNorm, userid, msgId,
|
|
518
|
+
await this.handleFileMessage(body, channelId, chatTypeNorm, userid, msgId, replyContext);
|
|
176
519
|
}
|
|
177
520
|
else if (msgtype === 'video') {
|
|
178
|
-
await this.handleVideoMessage(body, channelId, chatTypeNorm, userid, msgId,
|
|
521
|
+
await this.handleVideoMessage(body, channelId, chatTypeNorm, userid, msgId, replyContext);
|
|
179
522
|
}
|
|
180
523
|
else if (msgtype === 'mixed') {
|
|
181
|
-
await this.handleMixedMessage(body, channelId, chatTypeNorm, userid, msgId,
|
|
524
|
+
await this.handleMixedMessage(body, channelId, chatTypeNorm, userid, msgId, replyContext);
|
|
182
525
|
}
|
|
183
526
|
else {
|
|
184
527
|
await this.messageHandler({
|
|
185
528
|
channelId,
|
|
186
529
|
content: `[不支持的消息类型: ${msgtype}]`,
|
|
187
|
-
chatType: chatTypeNorm, peerId: userid, messageId: msgId,
|
|
530
|
+
chatType: chatTypeNorm, peerId: userid, messageId: msgId, replyContext,
|
|
188
531
|
});
|
|
189
532
|
}
|
|
190
533
|
}
|
|
191
534
|
// ── Inbound media handling ────────────────────────────────────────────────
|
|
192
|
-
async
|
|
535
|
+
async downloadInboundMedia(url, aesKey) {
|
|
536
|
+
if (!this.client)
|
|
537
|
+
throw new Error('WeCom client is unavailable');
|
|
538
|
+
validateWecomMediaUrl(url);
|
|
539
|
+
const result = await this.client.downloadFile(url, aesKey);
|
|
540
|
+
const maxBytes = 20 * 1024 * 1024;
|
|
541
|
+
if (result.buffer.length > maxBytes) {
|
|
542
|
+
throw new Error(`WeCom media exceeds ${maxBytes} bytes`);
|
|
543
|
+
}
|
|
544
|
+
return result;
|
|
545
|
+
}
|
|
546
|
+
async processQuote(quote, channelId) {
|
|
547
|
+
const result = { prefix: '', images: [] };
|
|
548
|
+
if (!quote?.msgtype)
|
|
549
|
+
return result;
|
|
550
|
+
try {
|
|
551
|
+
if (quote.msgtype === 'text') {
|
|
552
|
+
const text = String(quote.text?.content ?? '').trim();
|
|
553
|
+
if (text)
|
|
554
|
+
result.prefix = `[引用文本]\n${text}`;
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
if (quote.msgtype === 'voice') {
|
|
558
|
+
const text = String(quote.voice?.content ?? '').trim();
|
|
559
|
+
if (text)
|
|
560
|
+
result.prefix = `[引用语音转写]\n${text}`;
|
|
561
|
+
return result;
|
|
562
|
+
}
|
|
563
|
+
if (quote.msgtype === 'image' && quote.image?.url) {
|
|
564
|
+
const media = await this.downloadInboundMedia(quote.image.url, quote.image.aeskey);
|
|
565
|
+
const image = await bufferToInboundImage(media.buffer, { filename: media.filename });
|
|
566
|
+
if (!image)
|
|
567
|
+
throw new Error('引用图片格式不受支持');
|
|
568
|
+
result.images.push(image);
|
|
569
|
+
result.prefix = '[引用图片]';
|
|
570
|
+
return result;
|
|
571
|
+
}
|
|
572
|
+
if (quote.msgtype === 'file' && quote.file?.url) {
|
|
573
|
+
const media = await this.downloadInboundMedia(quote.file.url, quote.file.aeskey);
|
|
574
|
+
const fileName = sanitizeFileName(quote.file.filename || media.filename || 'quoted-file');
|
|
575
|
+
const projectPath = this.projectPathProvider
|
|
576
|
+
? await this.projectPathProvider(channelId)
|
|
577
|
+
: process.cwd();
|
|
578
|
+
const saved = saveToUploads(media.buffer, fileName, projectPath);
|
|
579
|
+
result.prefix = `[引用文件: ${saved.fileName}]\n文件已保存到:${saved.filePath}\n请使用 Read 工具读取并分析文件内容。`;
|
|
580
|
+
return result;
|
|
581
|
+
}
|
|
582
|
+
if (quote.msgtype === 'mixed' && Array.isArray(quote.mixed?.msg_item)) {
|
|
583
|
+
const textParts = [];
|
|
584
|
+
for (const item of quote.mixed.msg_item) {
|
|
585
|
+
if (item.msgtype === 'text' && item.text?.content) {
|
|
586
|
+
textParts.push(String(item.text.content));
|
|
587
|
+
}
|
|
588
|
+
else if (item.msgtype === 'image' && item.image?.url) {
|
|
589
|
+
const media = await this.downloadInboundMedia(item.image.url, item.image.aeskey);
|
|
590
|
+
const image = await bufferToInboundImage(media.buffer, { filename: media.filename });
|
|
591
|
+
if (image)
|
|
592
|
+
result.images.push(image);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
result.prefix = `[引用图文]\n${textParts.join('')}`.trim();
|
|
596
|
+
return result;
|
|
597
|
+
}
|
|
598
|
+
result.prefix = `[引用了暂不支持的消息类型: ${quote.msgtype}]`;
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
logger.warn(`[WeCom] Failed to process quote (${quote.msgtype}): ${String(error)}`);
|
|
602
|
+
result.prefix = `[引用内容读取失败: ${quote.msgtype}]`;
|
|
603
|
+
}
|
|
604
|
+
return result;
|
|
605
|
+
}
|
|
606
|
+
async handleImageMessage(body, channelId, chatType, peerId, msgId, replyContext) {
|
|
193
607
|
const imageUrl = body.image?.url;
|
|
194
608
|
const aeskey = body.image?.aeskey;
|
|
195
609
|
if (!imageUrl) {
|
|
196
610
|
logger.warn('[WeCom] Image message without url');
|
|
197
611
|
await this.messageHandler({
|
|
198
612
|
channelId, content: '[图片下载失败:缺少下载链接]',
|
|
199
|
-
chatType, peerId, messageId: msgId,
|
|
613
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
200
614
|
});
|
|
201
615
|
return;
|
|
202
616
|
}
|
|
203
617
|
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) {
|
|
618
|
+
const media = await this.downloadInboundMedia(imageUrl, aeskey);
|
|
619
|
+
const image = await bufferToInboundImage(media.buffer, { filename: media.filename });
|
|
620
|
+
if (image) {
|
|
216
621
|
await this.messageHandler({
|
|
217
622
|
channelId,
|
|
218
623
|
content: '用户发送了一张图片,请分析这张图片的内容。',
|
|
219
624
|
chatType, peerId, messageId: msgId,
|
|
220
|
-
images: [
|
|
625
|
+
images: [image], replyContext,
|
|
221
626
|
});
|
|
222
627
|
}
|
|
223
628
|
else {
|
|
224
629
|
logger.warn(`[WeCom] Image validation failed`);
|
|
225
630
|
await this.messageHandler({
|
|
226
631
|
channelId, content: '[图片验证失败]',
|
|
227
|
-
chatType, peerId, messageId: msgId,
|
|
632
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
228
633
|
});
|
|
229
634
|
}
|
|
230
635
|
}
|
|
@@ -232,97 +637,83 @@ export class WecomChannel {
|
|
|
232
637
|
logger.error('[WeCom] Failed to download image:', error);
|
|
233
638
|
await this.messageHandler({
|
|
234
639
|
channelId, content: '[图片下载失败]',
|
|
235
|
-
chatType, peerId, messageId: msgId,
|
|
640
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
236
641
|
});
|
|
237
642
|
}
|
|
238
643
|
}
|
|
239
|
-
async handleFileMessage(body, channelId, chatType, peerId, msgId,
|
|
644
|
+
async handleFileMessage(body, channelId, chatType, peerId, msgId, replyContext) {
|
|
240
645
|
const fileUrl = body.file?.url;
|
|
241
646
|
const aeskey = body.file?.aeskey;
|
|
242
|
-
const
|
|
647
|
+
const callbackFileName = body.file?.filename
|
|
648
|
+
? sanitizeFileName(body.file.filename)
|
|
649
|
+
: undefined;
|
|
650
|
+
const displayFileName = callbackFileName || 'file';
|
|
243
651
|
if (!fileUrl) {
|
|
244
652
|
logger.warn('[WeCom] File message without url');
|
|
245
653
|
await this.messageHandler({
|
|
246
|
-
channelId, content: `[文件下载失败:缺少下载链接] ${
|
|
247
|
-
chatType, peerId, messageId: msgId,
|
|
654
|
+
channelId, content: `[文件下载失败:缺少下载链接] ${displayFileName}`,
|
|
655
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
248
656
|
});
|
|
249
657
|
return;
|
|
250
658
|
}
|
|
251
659
|
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');
|
|
660
|
+
const media = await this.downloadInboundMedia(fileUrl, aeskey);
|
|
661
|
+
const fileName = sanitizeFileName(callbackFileName || media.filename || 'file');
|
|
262
662
|
const projectPath = this.projectPathProvider
|
|
263
663
|
? await this.projectPathProvider(channelId)
|
|
264
664
|
: process.cwd();
|
|
265
|
-
const { filePath } = saveToUploads(buffer,
|
|
665
|
+
const { filePath } = saveToUploads(media.buffer, fileName, projectPath);
|
|
266
666
|
await this.messageHandler({
|
|
267
667
|
channelId,
|
|
268
668
|
content: `用户发送了文件:${fileName}\n文件已保存到:${filePath}\n请使用 Read 工具读取并分析文件内容。`,
|
|
269
|
-
chatType, peerId, messageId: msgId,
|
|
669
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
270
670
|
});
|
|
271
671
|
}
|
|
272
672
|
catch (error) {
|
|
273
673
|
logger.error('[WeCom] Failed to download file:', error);
|
|
274
674
|
await this.messageHandler({
|
|
275
|
-
channelId, content: `[文件下载失败] ${
|
|
276
|
-
chatType, peerId, messageId: msgId,
|
|
675
|
+
channelId, content: `[文件下载失败] ${displayFileName}`,
|
|
676
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
277
677
|
});
|
|
278
678
|
}
|
|
279
679
|
}
|
|
280
|
-
async handleVideoMessage(body, channelId, chatType, peerId, msgId,
|
|
680
|
+
async handleVideoMessage(body, channelId, chatType, peerId, msgId, replyContext) {
|
|
281
681
|
const videoUrl = body.video?.url;
|
|
282
682
|
const aeskey = body.video?.aeskey;
|
|
283
683
|
if (!videoUrl) {
|
|
284
684
|
await this.messageHandler({
|
|
285
685
|
channelId, content: '[视频下载失败:缺少下载链接]',
|
|
286
|
-
chatType, peerId, messageId: msgId,
|
|
686
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
287
687
|
});
|
|
288
688
|
return;
|
|
289
689
|
}
|
|
290
690
|
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');
|
|
691
|
+
const media = await this.downloadInboundMedia(videoUrl, aeskey);
|
|
301
692
|
const projectPath = this.projectPathProvider
|
|
302
693
|
? await this.projectPathProvider(channelId)
|
|
303
694
|
: process.cwd();
|
|
304
695
|
const fileName = `video_${Date.now()}.mp4`;
|
|
305
|
-
const { filePath } = saveToUploads(buffer, fileName, projectPath);
|
|
696
|
+
const { filePath } = saveToUploads(media.buffer, fileName, projectPath);
|
|
306
697
|
await this.messageHandler({
|
|
307
698
|
channelId,
|
|
308
699
|
content: `用户发送了视频:${fileName}\n文件已保存到:${filePath}`,
|
|
309
|
-
chatType, peerId, messageId: msgId,
|
|
700
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
310
701
|
});
|
|
311
702
|
}
|
|
312
703
|
catch (error) {
|
|
313
704
|
logger.error('[WeCom] Failed to download video:', error);
|
|
314
705
|
await this.messageHandler({
|
|
315
706
|
channelId, content: '[视频下载失败]',
|
|
316
|
-
chatType, peerId, messageId: msgId,
|
|
707
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
317
708
|
});
|
|
318
709
|
}
|
|
319
710
|
}
|
|
320
|
-
async handleMixedMessage(body, channelId, chatType, peerId, msgId,
|
|
711
|
+
async handleMixedMessage(body, channelId, chatType, peerId, msgId, replyContext) {
|
|
321
712
|
const msgItems = body.mixed?.msg_item;
|
|
322
713
|
if (!Array.isArray(msgItems)) {
|
|
323
714
|
await this.messageHandler({
|
|
324
715
|
channelId, content: '[不支持的图文混排格式]',
|
|
325
|
-
chatType, peerId, messageId: msgId,
|
|
716
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
326
717
|
});
|
|
327
718
|
return;
|
|
328
719
|
}
|
|
@@ -334,20 +725,10 @@ export class WecomChannel {
|
|
|
334
725
|
}
|
|
335
726
|
else if (item.msgtype === 'image' && item.image?.url) {
|
|
336
727
|
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
|
-
}
|
|
728
|
+
const media = await this.downloadInboundMedia(item.image.url, item.image.aeskey);
|
|
729
|
+
const image = await bufferToInboundImage(media.buffer, { filename: media.filename });
|
|
730
|
+
if (image)
|
|
731
|
+
images.push(image);
|
|
351
732
|
}
|
|
352
733
|
catch (error) {
|
|
353
734
|
logger.warn('[WeCom] Failed to download mixed image:', error);
|
|
@@ -359,81 +740,132 @@ export class WecomChannel {
|
|
|
359
740
|
channelId, content: prompt, chatType,
|
|
360
741
|
peerId, messageId: msgId,
|
|
361
742
|
images: images.length > 0 ? images : undefined,
|
|
743
|
+
replyContext,
|
|
362
744
|
});
|
|
363
745
|
}
|
|
364
746
|
// ── Outbound: text ────────────────────────────────────────────────────────
|
|
365
|
-
async sendMessage(chatId, content) {
|
|
747
|
+
async sendMessage(chatId, content, replyContext) {
|
|
748
|
+
return this.sendStreamText(chatId, content, true, replyContext);
|
|
749
|
+
}
|
|
750
|
+
async sendStreamText(chatId, content, isFinal, replyContext) {
|
|
366
751
|
if (!content || content.trim() === '') {
|
|
367
752
|
logger.warn('[WeCom] Attempted to send empty message, skipping');
|
|
368
753
|
return;
|
|
369
754
|
}
|
|
370
|
-
|
|
371
|
-
logger.error('[WeCom] Client not connected, cannot send message');
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
755
|
+
const client = this.requireClient();
|
|
374
756
|
try {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
757
|
+
const stream = this.streamFromReplyContext(replyContext);
|
|
758
|
+
if (stream && !stream.finished) {
|
|
759
|
+
stream.accumulatedText += content;
|
|
760
|
+
stream.lastUpdatedAt = Date.now();
|
|
761
|
+
const visibleText = truncateUtf8Bytes(stream.accumulatedText, 20_480);
|
|
762
|
+
if (!stream.expired) {
|
|
763
|
+
try {
|
|
764
|
+
if (isFinal) {
|
|
765
|
+
await client.replyStream(stream.frame, stream.streamId, visibleText, true);
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
const result = await client.replyStreamNonBlocking(stream.frame, stream.streamId, visibleText, false);
|
|
769
|
+
if (result === 'skipped')
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
stream.started = true;
|
|
773
|
+
}
|
|
774
|
+
catch (error) {
|
|
775
|
+
if (wecomErrorCode(error) === 846608) {
|
|
776
|
+
stream.expired = true;
|
|
777
|
+
logger.warn(`[WeCom] Stream expired for reqId=${stream.reqId}; final reply will use proactive send`);
|
|
778
|
+
}
|
|
779
|
+
else {
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (isFinal) {
|
|
785
|
+
stream.finished = true;
|
|
786
|
+
if (stream.expired) {
|
|
787
|
+
await this.sendProactiveMarkdown(chatId, stream.accumulatedText);
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
const chunks = splitUtf8Bytes(stream.accumulatedText, 20_480);
|
|
791
|
+
for (const continuation of chunks.slice(1))
|
|
792
|
+
await this.sendProactiveMarkdown(chatId, continuation);
|
|
793
|
+
}
|
|
794
|
+
logger.debug(`[WeCom] Finished stream reply reqId=${stream.reqId} chatId=${chatId}`);
|
|
795
|
+
}
|
|
381
796
|
return;
|
|
382
797
|
}
|
|
383
|
-
|
|
384
|
-
await this.client.sendMessage(chatId, {
|
|
385
|
-
msgtype: 'markdown',
|
|
386
|
-
markdown: { content },
|
|
387
|
-
});
|
|
798
|
+
await this.sendProactiveMarkdown(chatId, content);
|
|
388
799
|
logger.debug(`[WeCom] Sent proactive message to ${chatId}`);
|
|
389
800
|
}
|
|
390
801
|
catch (error) {
|
|
391
802
|
logger.error(`[WeCom] sendMessage failed for ${chatId}:`, error.message);
|
|
803
|
+
throw error;
|
|
392
804
|
}
|
|
393
805
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
if (!
|
|
397
|
-
|
|
806
|
+
async finishStream(replyContext, fallbackText = '已完成') {
|
|
807
|
+
const stream = this.streamFromReplyContext(replyContext);
|
|
808
|
+
if (!stream || stream.finished)
|
|
809
|
+
return;
|
|
810
|
+
const client = this.requireClient();
|
|
811
|
+
const content = stream.accumulatedText || fallbackText;
|
|
812
|
+
if (stream.expired) {
|
|
813
|
+
stream.finished = true;
|
|
814
|
+
await this.sendProactiveMarkdown(stream.chatId, content);
|
|
398
815
|
return;
|
|
399
816
|
}
|
|
400
817
|
try {
|
|
401
|
-
|
|
818
|
+
await client.replyStream(stream.frame, stream.streamId, truncateUtf8Bytes(content, 20_480), true);
|
|
819
|
+
stream.finished = true;
|
|
820
|
+
stream.lastUpdatedAt = Date.now();
|
|
821
|
+
const chunks = splitUtf8Bytes(content, 20_480);
|
|
822
|
+
for (const continuation of chunks.slice(1))
|
|
823
|
+
await this.sendProactiveMarkdown(stream.chatId, continuation);
|
|
824
|
+
}
|
|
825
|
+
catch (error) {
|
|
826
|
+
if (wecomErrorCode(error) !== 846608)
|
|
827
|
+
throw error;
|
|
828
|
+
stream.expired = true;
|
|
829
|
+
stream.finished = true;
|
|
830
|
+
await this.sendProactiveMarkdown(stream.chatId, content);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
// ── Outbound: image ───────────────────────────────────────────────────────
|
|
834
|
+
async sendImage(chatId, png, replyContext) {
|
|
835
|
+
const client = this.requireClient();
|
|
836
|
+
try {
|
|
837
|
+
const result = await client.uploadMedia(png, {
|
|
402
838
|
type: 'image',
|
|
403
839
|
filename: 'image.png',
|
|
404
840
|
});
|
|
405
841
|
const mediaId = result?.media_id;
|
|
406
842
|
if (!mediaId) {
|
|
407
|
-
|
|
408
|
-
return;
|
|
843
|
+
throw new Error('WeCom media upload failed: no media_id');
|
|
409
844
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
this.activeStreams.delete(chatId);
|
|
845
|
+
const stream = this.streamFromReplyContext(replyContext);
|
|
846
|
+
if (stream && !stream.finished && !stream.started) {
|
|
847
|
+
await client.replyMedia(stream.frame, 'image', mediaId);
|
|
848
|
+
stream.finished = true;
|
|
415
849
|
}
|
|
416
850
|
else {
|
|
417
|
-
await this.
|
|
851
|
+
await this.finishStream(replyContext);
|
|
852
|
+
await client.sendMediaMessage(chatId, 'image', mediaId);
|
|
418
853
|
}
|
|
419
854
|
logger.debug(`[WeCom] Sent image to ${chatId}`);
|
|
420
855
|
}
|
|
421
856
|
catch (error) {
|
|
422
857
|
logger.error(`[WeCom] sendImage failed for ${chatId}:`, error.message);
|
|
858
|
+
throw error;
|
|
423
859
|
}
|
|
424
860
|
}
|
|
425
861
|
// ── Outbound: file ────────────────────────────────────────────────────────
|
|
426
|
-
async sendFile(chatId, filePath) {
|
|
427
|
-
|
|
428
|
-
logger.warn('[WeCom] Client not connected for sendFile');
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
862
|
+
async sendFile(chatId, filePath, replyContext) {
|
|
863
|
+
const client = this.requireClient();
|
|
431
864
|
try {
|
|
432
865
|
const fs = await import('fs');
|
|
433
866
|
const path = await import('path');
|
|
434
867
|
if (!fs.existsSync(filePath)) {
|
|
435
|
-
|
|
436
|
-
return;
|
|
868
|
+
throw new Error(`WeCom file not found: ${filePath}`);
|
|
437
869
|
}
|
|
438
870
|
// Detect image files → route to sendImage
|
|
439
871
|
const header = Buffer.alloc(12);
|
|
@@ -444,31 +876,159 @@ export class WecomChannel {
|
|
|
444
876
|
const ftype = await fileTypeFromBuffer(header);
|
|
445
877
|
if (ftype && ftype.mime.startsWith('image/')) {
|
|
446
878
|
const buf = fs.readFileSync(filePath);
|
|
447
|
-
return this.sendImage(chatId, buf);
|
|
879
|
+
return this.sendImage(chatId, buf, replyContext);
|
|
448
880
|
}
|
|
449
881
|
const buf = fs.readFileSync(filePath);
|
|
450
882
|
const fileName = path.basename(filePath);
|
|
451
|
-
const result = await
|
|
883
|
+
const result = await client.uploadMedia(buf, {
|
|
452
884
|
type: 'file',
|
|
453
885
|
filename: fileName,
|
|
454
886
|
});
|
|
455
887
|
const mediaId = result?.media_id;
|
|
456
888
|
if (!mediaId) {
|
|
457
|
-
|
|
458
|
-
return;
|
|
889
|
+
throw new Error('WeCom file upload failed: no media_id');
|
|
459
890
|
}
|
|
460
|
-
const stream = this.
|
|
461
|
-
if (stream) {
|
|
462
|
-
await
|
|
463
|
-
|
|
891
|
+
const stream = this.streamFromReplyContext(replyContext);
|
|
892
|
+
if (stream && !stream.finished && !stream.started) {
|
|
893
|
+
await client.replyMedia(stream.frame, 'file', mediaId);
|
|
894
|
+
stream.finished = true;
|
|
464
895
|
}
|
|
465
896
|
else {
|
|
466
|
-
await this.
|
|
897
|
+
await this.finishStream(replyContext);
|
|
898
|
+
await client.sendMediaMessage(chatId, 'file', mediaId);
|
|
467
899
|
}
|
|
468
900
|
logger.debug(`[WeCom] Sent file ${fileName} to ${chatId}`);
|
|
469
901
|
}
|
|
470
902
|
catch (error) {
|
|
471
903
|
logger.error(`[WeCom] sendFile failed for ${chatId}:`, error.message);
|
|
904
|
+
throw error;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
async sendInteraction(chatId, interaction, replyContext) {
|
|
908
|
+
if (!this.cardStore)
|
|
909
|
+
return false;
|
|
910
|
+
return this.withInteractionSendLock(chatId, async () => {
|
|
911
|
+
const client = this.requireClient();
|
|
912
|
+
const superseded = this.cardStore.listPendingByChat(chatId)
|
|
913
|
+
.filter(record => record.interaction.id !== interaction.id);
|
|
914
|
+
const taskId = createWecomCardTaskId(this.channelKey, interaction.id);
|
|
915
|
+
const card = buildWecomInteractionCard(interaction, taskId);
|
|
916
|
+
const record = {
|
|
917
|
+
taskId,
|
|
918
|
+
interaction,
|
|
919
|
+
chatId,
|
|
920
|
+
createdAt: Date.now(),
|
|
921
|
+
updatedAt: Date.now(),
|
|
922
|
+
expiresAt: interaction.expiresAt,
|
|
923
|
+
status: 'pending',
|
|
924
|
+
};
|
|
925
|
+
const stream = this.streamFromReplyContext(replyContext);
|
|
926
|
+
try {
|
|
927
|
+
if (stream && !stream.finished) {
|
|
928
|
+
if (stream.started) {
|
|
929
|
+
await client.replyStreamWithCard(stream.frame, stream.streamId, truncateUtf8Bytes(stream.accumulatedText, 20_480), true, { templateCard: card });
|
|
930
|
+
const chunks = splitUtf8Bytes(stream.accumulatedText, 20_480);
|
|
931
|
+
for (const continuation of chunks.slice(1))
|
|
932
|
+
await this.sendProactiveMarkdown(chatId, continuation);
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
await client.replyTemplateCard(stream.frame, card);
|
|
936
|
+
}
|
|
937
|
+
stream.finished = true;
|
|
938
|
+
}
|
|
939
|
+
else {
|
|
940
|
+
await client.sendMessage(chatId, {
|
|
941
|
+
msgtype: 'template_card',
|
|
942
|
+
template_card: card,
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
this.cardStore.set(record);
|
|
946
|
+
await this.invalidateSupersededCards(superseded);
|
|
947
|
+
logger.info(`[WeCom] Sent interaction card interaction=${interaction.id} taskId=${taskId}`);
|
|
948
|
+
return taskId;
|
|
949
|
+
}
|
|
950
|
+
catch (error) {
|
|
951
|
+
logger.error(`[WeCom] Failed to send interaction card ${interaction.id}: ${String(error)}`);
|
|
952
|
+
throw error;
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
async handleTemplateCardEvent(frame) {
|
|
957
|
+
const body = frame.body;
|
|
958
|
+
const client = this.client;
|
|
959
|
+
if (!body || !client || !this.cardStore)
|
|
960
|
+
return;
|
|
961
|
+
if (body.aibotid && body.aibotid !== this.config.botId) {
|
|
962
|
+
logger.warn(`[WeCom] Ignoring card callback for unexpected Bot ID: ${body.aibotid}`);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const duplicate = body.msgid ? this.isDuplicate(body.msgid) : false;
|
|
966
|
+
if (duplicate)
|
|
967
|
+
logger.debug(`[WeCom] Replaying terminal response for duplicate card callback: ${body.msgid}`);
|
|
968
|
+
const rawEvent = body.event;
|
|
969
|
+
const event = (rawEvent?.template_card_event ?? rawEvent);
|
|
970
|
+
const taskId = String(event?.task_id ?? '').trim();
|
|
971
|
+
if (!taskId)
|
|
972
|
+
return;
|
|
973
|
+
const record = this.cardStore.get(taskId);
|
|
974
|
+
if (!record) {
|
|
975
|
+
logger.warn(`[WeCom] Card callback has no local record: taskId=${taskId}`);
|
|
976
|
+
await client.updateTemplateCard(frame, buildWecomUnknownCard(taskId), body.from?.userid ? [body.from.userid] : undefined);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
const operatorId = body.from?.userid;
|
|
980
|
+
if (record.interaction.initiatorId && operatorId !== record.interaction.initiatorId) {
|
|
981
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'rejected'), operatorId ? [operatorId] : undefined);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (record.status !== 'pending' || (record.expiresAt && Date.now() > record.expiresAt)) {
|
|
985
|
+
const reason = record.invalidationReason || (record.status === 'resolved' ? '已处理' : '已过期');
|
|
986
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'invalidated', reason), operatorId ? [operatorId] : undefined);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
if (this.cardCallbacksInFlight.has(taskId)) {
|
|
990
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'unavailable', '正在处理'), operatorId ? [operatorId] : undefined);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
this.cardCallbacksInFlight.add(taskId);
|
|
994
|
+
try {
|
|
995
|
+
const parsed = parseWecomCardResponse(record, event, operatorId);
|
|
996
|
+
if (!parsed) {
|
|
997
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'invalidated', '无法识别操作'), operatorId ? [operatorId] : undefined);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (parsed.command) {
|
|
1001
|
+
if (!this.messageHandler) {
|
|
1002
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'unavailable'), operatorId ? [operatorId] : undefined);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
await this.messageHandler({
|
|
1006
|
+
channelId: record.chatId,
|
|
1007
|
+
content: parsed.command,
|
|
1008
|
+
chatType: body.chattype === 'group' ? 'group' : 'private',
|
|
1009
|
+
peerId: operatorId || '',
|
|
1010
|
+
messageId: `card-trigger-${body.msgid || Date.now()}`,
|
|
1011
|
+
source: 'card-trigger',
|
|
1012
|
+
});
|
|
1013
|
+
this.cardStore.resolve(taskId, parsed.command);
|
|
1014
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'resolved', parsed.command));
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (!parsed.response || !this.interactionCallback) {
|
|
1018
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'unavailable'), operatorId ? [operatorId] : undefined);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const accepted = (await this.interactionCallback(parsed.response)) !== false;
|
|
1022
|
+
if (!accepted) {
|
|
1023
|
+
const invalidated = this.cardStore.invalidateByInteractionId(record.interaction.id, 'backend_rejected') || record;
|
|
1024
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(invalidated, 'invalidated', '请求已失效'));
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
this.cardStore.resolve(taskId, parsed.response.action);
|
|
1028
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'resolved', parsed.response.action));
|
|
1029
|
+
}
|
|
1030
|
+
finally {
|
|
1031
|
+
this.cardCallbacksInFlight.delete(taskId);
|
|
472
1032
|
}
|
|
473
1033
|
}
|
|
474
1034
|
}
|
|
@@ -491,45 +1051,75 @@ export class WecomChannelPlugin {
|
|
|
491
1051
|
const adapter = {
|
|
492
1052
|
channelName: inst.name,
|
|
493
1053
|
channelKey: inst.name,
|
|
494
|
-
capabilities: { file: true, image: true, interaction:
|
|
1054
|
+
capabilities: { file: true, image: true, interaction: true, markdown: true, thought: false, status: true, thread: false, authenticatedApproval: true },
|
|
495
1055
|
send: async (envelope, payload) => {
|
|
496
1056
|
const channelId = envelope.channelId;
|
|
497
1057
|
switch (payload.kind) {
|
|
498
1058
|
case 'result.text':
|
|
1059
|
+
await channel.sendStreamText(channelId, payload.text, payload.isFinal, envelope.replyContext);
|
|
1060
|
+
return;
|
|
499
1061
|
case 'command.result':
|
|
500
1062
|
case 'command.error':
|
|
501
1063
|
case 'system.notice':
|
|
502
1064
|
case 'system.error':
|
|
503
1065
|
case 'result.error':
|
|
504
|
-
await channel.sendMessage(channelId, payload.text);
|
|
1066
|
+
await channel.sendMessage(channelId, payload.text, envelope.replyContext);
|
|
505
1067
|
return;
|
|
506
1068
|
case 'result.file':
|
|
507
|
-
await channel.sendFile(channelId, payload.filePath);
|
|
1069
|
+
await channel.sendFile(channelId, payload.filePath, envelope.replyContext);
|
|
508
1070
|
return;
|
|
509
1071
|
case 'result.image':
|
|
510
|
-
await channel.sendImage(channelId, payload.data);
|
|
1072
|
+
await channel.sendImage(channelId, payload.data, envelope.replyContext);
|
|
511
1073
|
return;
|
|
512
1074
|
case 'activity.batch': {
|
|
513
1075
|
const filtered = payload.items.filter((i) => !(i.kind === 'tool_result' && i.ok));
|
|
514
1076
|
const text = formatItemsAsText(filtered);
|
|
515
1077
|
if (text)
|
|
516
|
-
await channel.
|
|
1078
|
+
await channel.sendStreamText(channelId, text, false, envelope.replyContext);
|
|
517
1079
|
return;
|
|
518
1080
|
}
|
|
1081
|
+
case 'status.completed':
|
|
1082
|
+
await channel.finishStream(envelope.replyContext);
|
|
1083
|
+
return;
|
|
1084
|
+
case 'status.interrupted':
|
|
1085
|
+
case 'status.timeout':
|
|
1086
|
+
await channel.finishStream(envelope.replyContext, '任务已中止');
|
|
1087
|
+
return;
|
|
1088
|
+
case 'status.started':
|
|
1089
|
+
case 'status.queued':
|
|
1090
|
+
case 'status.progress': return;
|
|
519
1091
|
case 'status.requires_action':
|
|
520
|
-
await channel.sendMessage(channelId, '等待 owner 审批');
|
|
1092
|
+
await channel.sendMessage(channelId, '等待 owner 审批', envelope.replyContext);
|
|
521
1093
|
return;
|
|
522
1094
|
case 'status.error':
|
|
523
|
-
if (payload.metadata?.message)
|
|
524
|
-
await channel.sendMessage(channelId, payload.metadata.message);
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
1095
|
+
if (payload.metadata?.message) {
|
|
1096
|
+
await channel.sendMessage(channelId, payload.metadata.message, envelope.replyContext);
|
|
1097
|
+
}
|
|
1098
|
+
else {
|
|
1099
|
+
await channel.finishStream(envelope.replyContext, '任务执行失败');
|
|
1100
|
+
}
|
|
529
1101
|
return;
|
|
1102
|
+
case 'interaction': {
|
|
1103
|
+
try {
|
|
1104
|
+
const taskId = await channel.sendInteraction(channelId, payload.interaction, envelope.replyContext);
|
|
1105
|
+
if (taskId)
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
catch (error) {
|
|
1109
|
+
logger.warn(`[WeCom] Interaction card delivery failed, using text fallback: ${error instanceof Error ? error.message : String(error)}`);
|
|
1110
|
+
}
|
|
1111
|
+
if (payload.fallbackText) {
|
|
1112
|
+
await channel.sendMessage(channelId, payload.fallbackText, envelope.replyContext);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
throw new Error('WeCom interaction delivery failed and no fallback text was provided');
|
|
1116
|
+
}
|
|
530
1117
|
default: return;
|
|
531
1118
|
}
|
|
532
1119
|
},
|
|
1120
|
+
onInteraction: (callback) => channel.onInteraction(callback),
|
|
1121
|
+
onInteractionInvalidated: (callback) => channel.onInteractionInvalidated(callback),
|
|
1122
|
+
invalidateInteraction: (interactionId, reason) => channel.invalidateInteraction(interactionId, reason),
|
|
533
1123
|
};
|
|
534
1124
|
const policy = {
|
|
535
1125
|
canSwitchProject: (_, identity) => identity === 'owner' || identity === 'admin',
|
|
@@ -552,13 +1142,15 @@ export class WecomChannelPlugin {
|
|
|
552
1142
|
onProjectPathRequest: () => Promise.resolve(ctx.defaultProjectPath),
|
|
553
1143
|
registerBridge(bridge, channelType) {
|
|
554
1144
|
bridge.register(adapter.channelName, (handler) => channel.onMessage(async (event) => {
|
|
555
|
-
handler({
|
|
1145
|
+
await handler({
|
|
556
1146
|
channel: adapter.channelName, channelType, channelId: event.channelId,
|
|
557
1147
|
selfAID: ctx.agentName, content: event.content, images: event.images,
|
|
558
1148
|
chatType: event.chatType || 'private', peerId: event.peerId || '',
|
|
559
1149
|
peerName: event.peerName, messageId: event.messageId,
|
|
1150
|
+
replyContext: event.replyContext,
|
|
1151
|
+
source: event.source,
|
|
560
1152
|
});
|
|
561
|
-
}), (channelId, text) => channel.sendMessage(channelId, text), adapter, channelType);
|
|
1153
|
+
}), (channelId, text, replyContext) => channel.sendMessage(channelId, text, replyContext), adapter, channelType);
|
|
562
1154
|
},
|
|
563
1155
|
};
|
|
564
1156
|
}
|