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.
Files changed (124) hide show
  1. package/CHANGELOG.md +44 -793
  2. package/dist/agents/claude-runner.js +197 -17
  3. package/dist/agents/codex-runner.js +46 -3
  4. package/dist/aun/outbox.js +8 -0
  5. package/dist/channels/aun.js +21 -4
  6. package/dist/channels/contact-bind-code.js +134 -0
  7. package/dist/channels/dingtalk.js +979 -149
  8. package/dist/channels/feishu.js +130 -54
  9. package/dist/channels/wecom-card.js +101 -0
  10. package/dist/channels/wecom-onboarding.js +82 -0
  11. package/dist/channels/wecom-state.js +191 -0
  12. package/dist/channels/wecom.js +755 -163
  13. package/dist/cli/agent-command.js +2 -1
  14. package/dist/cli/aun-commands.js +88 -33
  15. package/dist/cli/bench.js +2 -2
  16. package/dist/cli/contact.js +71 -0
  17. package/dist/cli/ctl-command.js +2 -2
  18. package/dist/cli/daemon-commands.js +77 -214
  19. package/dist/cli/handoff-command.js +2 -2
  20. package/dist/cli/help.js +9 -5
  21. package/dist/cli/index.js +132 -116
  22. package/dist/cli/init-channel.js +92 -97
  23. package/dist/cli/init.js +63 -26
  24. package/dist/cli/model.js +2 -1
  25. package/dist/cli/net-check.js +2 -2
  26. package/dist/cli/queue-command.js +30 -6
  27. package/dist/cli/raw-key-input.js +25 -0
  28. package/dist/cli/response.js +5 -6
  29. package/dist/cli/restart-monitor.js +25 -1
  30. package/dist/cli/stats.js +6 -4
  31. package/dist/cli/trigger-command.js +55 -15
  32. package/dist/cli/version.js +6 -1
  33. package/dist/config/builtin-role-templates.js +22 -10
  34. package/dist/config/builtin-roles.js +7 -1
  35. package/dist/config/config-manager.js +221 -17
  36. package/dist/config/contact-alias.js +68 -0
  37. package/dist/config/contact-book-store.js +454 -0
  38. package/dist/config/contact-book-v2-startup.js +35 -0
  39. package/dist/config/contact-book.js +156 -303
  40. package/dist/config/contact-operation-service.js +110 -0
  41. package/dist/config/peer-role-resolver.js +133 -54
  42. package/dist/config/role-ranks.js +18 -0
  43. package/dist/config/role-service.js +16 -19
  44. package/dist/config/role-store.js +16 -5
  45. package/dist/config/roles.js +10 -1
  46. package/dist/config-store.js +0 -2
  47. package/dist/core/auth/authorization-audit.js +10 -1
  48. package/dist/core/auth/operation-authorizer.js +2 -0
  49. package/dist/core/auth/operation-catalog.js +56 -0
  50. package/dist/core/command/command-handler.js +105 -10
  51. package/dist/core/command/connect-menu.js +374 -0
  52. package/dist/core/command/menu-handler.js +114 -16
  53. package/dist/core/command/role-menu.js +128 -29
  54. package/dist/core/command/slash-handler.js +28 -18
  55. package/dist/core/daemon-file-cache.js +12 -6
  56. package/dist/core/event-catalog.js +70 -0
  57. package/dist/core/evolagent-registry.js +0 -1
  58. package/dist/core/evolagent.js +20 -9
  59. package/dist/core/message/im-renderer.js +2 -0
  60. package/dist/core/message/message-bridge.js +79 -8
  61. package/dist/core/message/message-queue.js +10 -0
  62. package/dist/core/message/message-utils.js +8 -2
  63. package/dist/core/message/response-engine.js +269 -25
  64. package/dist/core/message/send-receipt.js +24 -0
  65. package/dist/core/message/stream-debouncer.js +11 -2
  66. package/dist/core/permission/tool-policy.js +47 -15
  67. package/dist/core/protected-paths.js +2 -0
  68. package/dist/core/session/session-fs-store.js +44 -3
  69. package/dist/core/session/session-manager.js +61 -5
  70. package/dist/index.js +62 -6
  71. package/dist/ipc.js +34 -5
  72. package/dist/stats/billing.js +20 -8
  73. package/dist/trigger/manager.js +3 -0
  74. package/dist/trigger/parser.js +77 -2
  75. package/dist/trigger/patch.js +8 -1
  76. package/dist/trigger/scheduler.js +3 -1
  77. package/dist/trigger/validation.js +13 -0
  78. package/dist/utils/aid-bind.js +43 -29
  79. package/dist/utils/instance-registry.js +14 -7
  80. package/dist/utils/log-writer.js +46 -0
  81. package/dist/utils/media-cache.js +4 -1
  82. package/dist/utils/model-prices.jsonl +6 -3
  83. package/dist/utils/restart-safety.js +31 -0
  84. package/dist/utils/system-memory.js +62 -0
  85. package/kits/docs/INDEX.md +2 -1
  86. package/kits/docs/evolcore/INDEX.md +5 -3
  87. package/kits/docs/evolcore/agent.md +9 -1
  88. package/kits/docs/evolcore/aid.md +5 -2
  89. package/kits/docs/evolcore/contact.md +57 -0
  90. package/kits/docs/evolcore/fs.md +9 -0
  91. package/kits/docs/evolcore/group.md +12 -3
  92. package/kits/docs/evolcore/model.md +4 -1
  93. package/kits/docs/evolcore/msg.md +9 -3
  94. package/kits/docs/evolcore/response.md +16 -21
  95. package/kits/docs/evolcore/rpc.md +2 -0
  96. package/kits/docs/evolcore/stats.md +15 -2
  97. package/kits/docs/evolcore/storage.md +1 -0
  98. package/kits/docs/evolcore/trigger.md +17 -2
  99. package/kits/eck_manifest.json +12 -0
  100. package/kits/migrations/migrate-contact-book-v2.mjs +747 -0
  101. package/kits/schemas/_meta.json +7 -4
  102. package/kits/schemas/agent-config.schema.6.json +322 -0
  103. package/kits/schemas/contact-book.schema.2.json +43 -0
  104. package/kits/schemas/relation-config.schema.5.json +47 -0
  105. package/kits/schemas/role-config.schema.1.json +1 -0
  106. package/kits/schemas/role-registry.schema.1.json +2 -2
  107. package/kits/templates/roles/admin.json +1 -0
  108. package/kits/templates/roles/member.json +1 -0
  109. package/kits/templates/roles/owner.json +1 -0
  110. package/kits/templates/roles/visitor.json +1 -0
  111. package/kits/templates/system-fragments/commands.md +3 -1
  112. package/package.json +4 -4
  113. package/assets/brand/evolcore/README.md +0 -19
  114. package/assets/brand/evolcore/evolcore-app-icon.png +0 -0
  115. package/assets/brand/evolcore/evolcore-app-icon.svg +0 -13
  116. package/assets/brand/evolcore/evolcore-brand-board.png +0 -0
  117. package/assets/brand/evolcore/evolcore-brand-board.svg +0 -126
  118. package/assets/brand/evolcore/evolcore-logo-kit.zip +0 -0
  119. package/assets/brand/evolcore/evolcore-logo-reverse.png +0 -0
  120. package/assets/brand/evolcore/evolcore-logo-reverse.svg +0 -14
  121. package/assets/brand/evolcore/evolcore-logo.png +0 -0
  122. package/assets/brand/evolcore/evolcore-logo.svg +0 -14
  123. package/assets/brand/evolcore/evolcore-mark.png +0 -0
  124. package/assets/brand/evolcore/evolcore-mark.svg +0 -10
@@ -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
- connected = false;
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
- // Stream reply state: reqId → { streamId, frame }
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' && chatid ? chatid : userid;
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 { WSClient } = await requireOptional('@wecom/aibot-node-sdk');
48
- this.client = new WSClient({ botId, secret });
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
- this.client.on('message', (frame) => {
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
- this.client.on('event.enter_chat', (frame) => {
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
- this.client.on('authenticated', () => {
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
- this.client.on('disconnected', (reason) => {
221
+ client.on('disconnected', (reason) => {
222
+ if (this.client !== client)
223
+ return;
67
224
  logger.warn(`[WeCom] WebSocket disconnected: ${reason}`);
68
- this.connected = false;
225
+ if (this.connectionState !== 'kicked')
226
+ this.connectionState = 'disconnected';
227
+ this.lastConnectionError = reason;
228
+ this.expireActiveStreams();
69
229
  });
70
- this.client.on('reconnecting', (attempt) => {
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
- this.client.on('error', (error) => {
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
- this.client.connect();
77
- this.connected = true;
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.connected = false;
294
+ this.connectionState = 'idle';
90
295
  if (this.cleanupInterval) {
91
296
  clearInterval(this.cleanupInterval);
92
297
  this.cleanupInterval = null;
93
298
  }
94
- if (this.client) {
299
+ const client = this.client;
300
+ this.client = null;
301
+ if (client) {
95
302
  try {
96
- this.client.disconnect();
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 { connected: this.connected };
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
- // Store frame for stream replies
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
- // Handle quote/reference
150
- let content = text;
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, frame);
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, frame);
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, frame);
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, frame);
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 handleImageMessage(body, channelId, chatType, peerId, msgId, frame) {
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
- let buffer;
205
- if (this.client && aeskey) {
206
- const result = await this.client.downloadFile(imageUrl, aeskey);
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: [{ data: buffer.toString('base64'), mimeType: result.mime }],
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, frame) {
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 fileName = body.file?.filename || 'unknown';
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: `[文件下载失败:缺少下载链接] ${fileName}`,
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
- let buffer;
253
- if (this.client && aeskey) {
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, sanitizeFileName(fileName), projectPath);
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: `[文件下载失败] ${fileName}`,
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, frame) {
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
- let buffer;
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, frame) {
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
- let buffer;
338
- if (this.client && item.image.aeskey) {
339
- const result = await this.client.downloadFile(item.image.url, item.image.aeskey);
340
- buffer = result.buffer;
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
- if (!this.client) {
371
- logger.error('[WeCom] Client not connected, cannot send message');
372
- return;
373
- }
755
+ const client = this.requireClient();
374
756
  try {
375
- // Try stream reply first (responds to a specific user message)
376
- const stream = this.activeStreams.get(chatId);
377
- if (stream) {
378
- await this.client.replyStream(stream.frame, stream.streamId, content, true);
379
- this.activeStreams.delete(chatId);
380
- logger.debug(`[WeCom] Sent stream reply to ${chatId}`);
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
- // Fallback: proactive send (markdown)
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
- // ── Outbound: image ───────────────────────────────────────────────────────
395
- async sendImage(chatId, png) {
396
- if (!this.client) {
397
- logger.warn('[WeCom] Client not connected for sendImage');
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
- const result = await this.client.uploadMedia(png, {
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
- logger.error('[WeCom] Media upload failed: no media_id');
408
- return;
843
+ throw new Error('WeCom media upload failed: no media_id');
409
844
  }
410
- // Try reply media if we have an active frame, else proactive send
411
- const stream = this.activeStreams.get(chatId);
412
- if (stream) {
413
- await this.client.replyMedia(stream.frame, 'image', mediaId);
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.client.sendMediaMessage(chatId, 'image', mediaId);
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
- if (!this.client) {
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
- logger.error(`[WeCom] File not found: ${filePath}`);
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 this.client.uploadMedia(buf, {
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
- logger.error('[WeCom] File upload failed: no media_id');
458
- return;
889
+ throw new Error('WeCom file upload failed: no media_id');
459
890
  }
460
- const stream = this.activeStreams.get(chatId);
461
- if (stream) {
462
- await this.client.replyMedia(stream.frame, 'file', mediaId);
463
- this.activeStreams.delete(chatId);
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.client.sendMediaMessage(chatId, 'file', mediaId);
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: false, markdown: true, thought: false, status: false, thread: false, authenticatedApproval: true },
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.sendMessage(channelId, text);
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
- return;
526
- case 'interaction':
527
- if (payload.fallbackText)
528
- await channel.sendMessage(channelId, payload.fallbackText);
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
  }