evolcore 0.0.2 → 0.0.4

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