evolcore 0.0.3 → 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 (143) hide show
  1. package/CHANGELOG.md +64 -0
  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 +1 -31
  6. package/dist/agents/codex-app-server-client.js +68 -0
  7. package/dist/agents/codex-runner.js +157 -5
  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/channels/aun.js +220 -112
  14. package/dist/channels/daemon.js +18 -4
  15. package/dist/channels/dingtalk.js +52 -137
  16. package/dist/channels/feishu.js +56 -4
  17. package/dist/channels/qqbot.js +23 -1
  18. package/dist/channels/wechat.js +303 -155
  19. package/dist/channels/wecom.js +383 -7
  20. package/dist/cli/aun-commands.js +11 -11
  21. package/dist/cli/handoff-command.js +2 -1
  22. package/dist/cli/init-channel.js +185 -67
  23. package/dist/cli/init.js +7 -5
  24. package/dist/cli/trigger-command.js +92 -11
  25. package/dist/config/access-policy-domain.js +38 -0
  26. package/dist/config/access-policy.js +134 -0
  27. package/dist/config/builtin-role-templates.js +27 -14
  28. package/dist/config/builtin-roles.js +25 -6
  29. package/dist/config/config-field-policy.js +17 -5
  30. package/dist/config/config-manager.js +198 -22
  31. package/dist/config/config-operation-service.js +35 -38
  32. package/dist/config/contact-bind-code.js +274 -0
  33. package/dist/config/contact-book-store.js +173 -5
  34. package/dist/config/contact-book.js +32 -0
  35. package/dist/config/contact-operation-service.js +9 -3
  36. package/dist/config/contact-request-service.js +331 -0
  37. package/dist/config/peer-role-resolver.js +3 -1
  38. package/dist/config/schema-registry.js +49 -24
  39. package/dist/config-store.js +14 -2
  40. package/dist/core/auth/agent-delegation.js +105 -11
  41. package/dist/core/auth/authorization-audit.js +6 -0
  42. package/dist/core/auth/operation-authorizer.js +8 -0
  43. package/dist/core/auth/operation-catalog.js +51 -3
  44. package/dist/core/auth/trigger-authorization.js +15 -0
  45. package/dist/core/bootstrap-service.js +2 -9
  46. package/dist/core/channel-loader.js +6 -2
  47. package/dist/core/command/command-handler.js +10 -13
  48. package/dist/core/command/connect-menu.js +249 -24
  49. package/dist/core/command/evol-menu-version-gate.js +38 -0
  50. package/dist/core/command/group-menu.js +421 -0
  51. package/dist/core/command/menu-handler.js +269 -85
  52. package/dist/core/command/menu-protocol.js +8 -2
  53. package/dist/core/command/menu-token-store.js +102 -0
  54. package/dist/core/command/slash-gate.js +1 -1
  55. package/dist/core/command/slash-handler.js +158 -31
  56. package/dist/core/daemon-file-cache.js +28 -0
  57. package/dist/core/event-catalog.js +29 -0
  58. package/dist/core/evolagent-registry.js +4 -39
  59. package/dist/core/handoff/runtime.js +162 -37
  60. package/dist/core/handoff/store.js +46 -2
  61. package/dist/core/handoff/types.js +1 -0
  62. package/dist/core/inference/text-inference.js +16 -74
  63. package/dist/core/message/file-markers.js +7 -0
  64. package/dist/core/message/im-renderer.js +18 -14
  65. package/dist/core/message/inbound-admission.js +124 -0
  66. package/dist/core/message/message-bridge.js +616 -74
  67. package/dist/core/message/message-log.js +1 -0
  68. package/dist/core/message/message-queue.js +185 -10
  69. package/dist/core/message/peer-mode.js +7 -8
  70. package/dist/core/message/response-engine.js +381 -79
  71. package/dist/core/permission/approval-gateway.js +17 -10
  72. package/dist/core/permission/ec-command-parser.js +101 -46
  73. package/dist/core/permission/tool-policy.js +69 -24
  74. package/dist/core/protected-paths.js +36 -11
  75. package/dist/core/session/session-manager.js +25 -3
  76. package/dist/core/session/session-mapper.js +2 -0
  77. package/dist/core/session/session-renew.js +125 -69
  78. package/dist/core/session/session-turn-coordinator.js +5 -1
  79. package/dist/eck/kit-renderer.js +12 -1
  80. package/dist/eck/message-renderer.js +79 -1
  81. package/dist/index.js +224 -89
  82. package/dist/ipc.js +81 -4
  83. package/dist/paths.js +3 -0
  84. package/dist/response-system/config-resolver.js +29 -0
  85. package/dist/response-system/coordinator.js +8 -32
  86. package/dist/response-system/engines/v1/proactive-flow.js +1 -1
  87. package/dist/response-system/index.js +1 -0
  88. package/dist/response-system/modes/single-session/index.js +7 -4
  89. package/dist/trigger/parser.js +55 -15
  90. package/dist/trigger/patch.js +4 -1
  91. package/dist/trigger/scheduler.js +441 -39
  92. package/dist/utils/error-dict.json +7 -0
  93. package/dist/utils/evolcore-version.js +21 -0
  94. package/dist/utils/logger.js +2 -2
  95. package/dist/utils/stable-semver.js +21 -0
  96. package/dist/utils/stats.js +33 -9
  97. package/kits/docs/channels/aun.md +4 -13
  98. package/kits/docs/evolcore/INDEX.md +1 -1
  99. package/kits/docs/evolcore/config.md +47 -2
  100. package/kits/docs/evolcore/contact.md +7 -3
  101. package/kits/docs/evolcore/group-rules.md +46 -4
  102. package/kits/docs/evolcore/group.md +4 -4
  103. package/kits/docs/evolcore/msg.md +5 -5
  104. package/kits/docs/evolcore/trigger.md +25 -8
  105. package/kits/docs/path-registry.md +36 -17
  106. package/kits/eck_message_manifest.json +12 -1
  107. package/kits/migrations/migrate-contact-book-v2.mjs +7 -0
  108. package/kits/rules/01-overview.md +17 -7
  109. package/kits/rules/02-navigation.md +38 -18
  110. package/kits/rules/03-identity.md +28 -24
  111. package/kits/rules/04-relation.md +44 -28
  112. package/kits/rules/05-venue.md +31 -15
  113. package/kits/rules/06-channel.md +11 -7
  114. package/kits/schemas/_meta.json +18 -7
  115. package/kits/schemas/agent-config.schema.7.json +303 -0
  116. package/kits/schemas/agent-config.schema.8.json +304 -0
  117. package/kits/schemas/agent-config.schema.9.json +374 -0
  118. package/kits/schemas/contact-book.schema.3.json +67 -0
  119. package/kits/schemas/daemon.schema.1.json +3 -2
  120. package/kits/schemas/daemon.schema.2.json +101 -0
  121. package/kits/schemas/daemon.schema.3.json +123 -0
  122. package/kits/schemas/defaults.schema.2.json +85 -0
  123. package/kits/schemas/defaults.schema.3.json +73 -0
  124. package/kits/schemas/relation-config.schema.6.json +46 -0
  125. package/kits/schemas/relation-config.schema.7.json +59 -0
  126. package/kits/schemas/single-session.schema.2.json +57 -0
  127. package/kits/templates/message-fragments/handoff-context-to-target.md +9 -0
  128. package/kits/templates/message-fragments/handoff-request-to-target.md +14 -8
  129. package/kits/templates/message-fragments/handoff-response-to-origin.md +5 -6
  130. package/kits/templates/roles/admin.json +46 -0
  131. package/kits/templates/roles/member.json +4 -0
  132. package/kits/templates/roles/visitor.json +4 -0
  133. package/kits/templates/system-fragments/channel.md +12 -2
  134. package/kits/templates/system-fragments/identity.md +3 -1
  135. package/kits/templates/system-fragments/relation.md +1 -1
  136. package/kits/templates/system-fragments/session.md +6 -2
  137. package/package.json +4 -2
  138. package/MIGRATION-0.5.0.md +0 -378
  139. package/ROLE_ACCESS_CONTROL.md +0 -174
  140. package/dist/channels/contact-bind-code.js +0 -134
  141. package/dist/channels/wecom-card.js +0 -101
  142. package/dist/channels/wecom-onboarding.js +0 -82
  143. package/dist/channels/wecom-state.js +0 -191
@@ -7,11 +7,31 @@ import { sanitizeFileName, saveToUploads, safeFetch, bufferToInboundImage } from
7
7
  import { markdownToPlainText } from '../utils/markdown-to-plain-text.js';
8
8
  import { DEFAULT_FLUSH_DELAY_SECONDS } from '../types.js';
9
9
  import { formatItemsAsText } from '../core/message/items-formatter.js';
10
+ import { createSendFileMarkerPattern } from '../core/message/file-markers.js';
10
11
  import { initWelcomeManager, sendWelcomeIfNeeded } from '../utils/welcome.js';
11
- const CHANNEL_VERSION = '1.0.0';
12
+ import { ContactBindCodeRegistry, handleExplicitContactBindCommand, } from '../config/contact-bind-code.js';
13
+ const wechatContactBinds = new ContactBindCodeRegistry({
14
+ channelType: 'wechat',
15
+ displayName: '微信',
16
+ initCommand: 'ec init wechat',
17
+ });
18
+ export function registerPendingWechatContactBind(req) {
19
+ return wechatContactBinds.register(req);
20
+ }
21
+ export function handlePendingWechatContactBindMessage(ctx) {
22
+ return handleExplicitContactBindCommand(wechatContactBinds, ctx);
23
+ }
24
+ export function getPendingWechatContactBind(selfAid, channelName, primaryId) {
25
+ return wechatContactBinds.get(selfAid, channelName, primaryId);
26
+ }
27
+ export function clearPendingWechatContactBinds() {
28
+ wechatContactBinds.clear();
29
+ }
30
+ const CHANNEL_VERSION = '2.4.6';
12
31
  const ILINK_APP_ID = 'bot';
13
32
  // iLink-App-ClientVersion: major<<16 | minor<<8 | patch (uint32)
14
- const ILINK_APP_CLIENT_VERSION = String((1 << 16) | (0 << 8) | 0); // 1.0.0 = 65536
33
+ const ILINK_APP_CLIENT_VERSION = String((2 << 16) | (4 << 8) | 6); // 2.4.6 = 132102
34
+ const BOT_AGENT = 'EvolCore';
15
35
  const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
16
36
  const DEFAULT_API_TIMEOUT_MS = 15_000;
17
37
  const DEFAULT_CONFIG_TIMEOUT_MS = 10_000;
@@ -20,8 +40,65 @@ const BACKOFF_DELAY_MS = 30_000;
20
40
  const RETRY_DELAY_MS = 2_000;
21
41
  const TYPING_TICKET_TTL_MS = 5 * 60 * 1000; // 5 min cache
22
42
  const SESSION_EXPIRED_ERRCODE = -14;
23
- const SESSION_RETRY_DELAY_MS = 30_000; // 短暂停:30s 后重试一次
24
- const SESSION_PAUSE_DURATION_MS = 10 * 60 * 1000; // 长暂停:10 分钟
43
+ export class WechatApiError extends Error {
44
+ endpoint;
45
+ ret;
46
+ errcode;
47
+ errmsg;
48
+ code;
49
+ constructor(endpoint, ret, errcode, errmsg) {
50
+ const code = errcode !== undefined && errcode !== 0 ? errcode : ret;
51
+ super(`${endpoint} failed: ret=${ret ?? 'unset'} errcode=${errcode ?? 'unset'} errmsg=${errmsg ?? '(none)'}`);
52
+ this.endpoint = endpoint;
53
+ this.ret = ret;
54
+ this.errcode = errcode;
55
+ this.errmsg = errmsg;
56
+ this.name = 'WechatApiError';
57
+ this.code = code;
58
+ }
59
+ hasCode(code) {
60
+ return this.ret === code || this.errcode === code;
61
+ }
62
+ }
63
+ export class WechatContextTokenRefreshRequiredError extends Error {
64
+ recipient;
65
+ constructor(recipient, options) {
66
+ super(`sendmessage ret=-2 for ${recipient}; waiting for an updated context_token`);
67
+ this.recipient = recipient;
68
+ this.name = 'WechatContextTokenRefreshRequiredError';
69
+ if (options?.cause !== undefined) {
70
+ this.cause = options.cause;
71
+ }
72
+ }
73
+ }
74
+ /** Classify fetch/undici failures using the categories exposed by Tencent 2.4.6. */
75
+ export function classifyFetchError(err) {
76
+ if (err instanceof Error && err.name === 'AbortError') {
77
+ return { type: 'timeout', description: 'request timeout' };
78
+ }
79
+ const root = err;
80
+ const cause = root?.cause;
81
+ const causeCode = typeof cause === 'object' && cause ? cause.code ?? '' : '';
82
+ const rootCode = root?.code ?? '';
83
+ const matchedCode = causeCode || rootCode || (typeof cause === 'string' ? cause : '');
84
+ const searchable = `${String(cause ?? err ?? '')} ${causeCode} ${rootCode}`;
85
+ if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(searchable)) {
86
+ return { type: 'dns', description: 'DNS resolution failed, check DNS configuration', ...(matchedCode ? { code: matchedCode } : {}) };
87
+ }
88
+ if (/ECONNREFUSED/i.test(searchable)) {
89
+ return { type: 'tcp', description: 'TCP connection refused', ...(matchedCode ? { code: matchedCode } : {}) };
90
+ }
91
+ if (/UND_ERR_CONNECT_TIMEOUT|ETIMEDOUT|ENETUNREACH|EHOSTUNREACH/i.test(searchable)) {
92
+ return { type: 'tcp', description: 'TCP connection timeout or unreachable', ...(matchedCode ? { code: matchedCode } : {}) };
93
+ }
94
+ if (/UND_ERR_SOCKET|SSL|TLS|CERT|UNABLE_TO_VERIFY|DEPTH_ZERO/i.test(searchable)) {
95
+ return { type: 'tls', description: 'TLS handshake error', ...(matchedCode ? { code: matchedCode } : {}) };
96
+ }
97
+ return { type: 'unknown', description: 'network request failed', ...(matchedCode ? { code: matchedCode } : {}) };
98
+ }
99
+ function buildBaseInfo() {
100
+ return { channel_version: CHANNEL_VERSION, bot_agent: BOT_AGENT };
101
+ }
25
102
  const MSG_TYPE_USER = 1;
26
103
  const MSG_TYPE_BOT = 2;
27
104
  const MSG_ITEM_TEXT = 1;
@@ -109,14 +186,18 @@ export class WechatChannel {
109
186
  recallHandler;
110
187
  abortController;
111
188
  connected = false;
189
+ connectionState = 'disconnected';
190
+ lastError;
191
+ reauthRequiredAt;
192
+ inflightHandlers = new Set();
112
193
  // 内部状态(不外泄到核心层)
113
194
  contextTokenCache = new Map();
195
+ rejectedContextTokens = new Map();
196
+ pendingContextMessages = new Map();
114
197
  typingTicketCache = new Map();
115
198
  getUpdatesBuf = '';
116
199
  syncBufPath;
117
200
  contextTokensPath;
118
- // Session expired 状态
119
- sessionPausedUntil = 0;
120
201
  onSessionExpired;
121
202
  eventBus;
122
203
  // Project path resolver(用于保存文件到 uploads 目录)
@@ -150,14 +231,17 @@ export class WechatChannel {
150
231
  setEventBus(bus) {
151
232
  this.eventBus = bus;
152
233
  }
153
- /** 当前是否处于 session 暂停状态 */
234
+ /** 当前 token 是否已失效,保留旧方法名以兼容现有调用方。 */
154
235
  isSessionPaused() {
155
- return Date.now() < this.sessionPausedUntil;
236
+ return this.connectionState === 'reauth_required';
156
237
  }
157
238
  async connect() {
158
239
  if (!this.config.token) {
159
240
  throw new Error('WeChat token not configured');
160
241
  }
242
+ if (this.connectionState === 'reauth_required') {
243
+ throw new Error('WeChat reauthentication required; run: ec init wechat');
244
+ }
161
245
  // 恢复游标
162
246
  try {
163
247
  if (fs.existsSync(this.syncBufPath)) {
@@ -173,6 +257,8 @@ export class WechatChannel {
173
257
  await this.notifyLifecycle('notifystart');
174
258
  }
175
259
  catch (err) {
260
+ if (this.isSessionPaused())
261
+ throw err;
176
262
  logger.warn('[WeChat] notifyStart failed during startup (ignored):', err);
177
263
  }
178
264
  this.abortController = new AbortController();
@@ -182,8 +268,14 @@ export class WechatChannel {
182
268
  return;
183
269
  logger.error('[WeChat] Poll loop fatal error:', err);
184
270
  this.connected = false;
271
+ if (this.connectionState !== 'reauth_required') {
272
+ this.connectionState = 'disconnected';
273
+ this.lastError = err instanceof Error ? err.message : String(err);
274
+ }
185
275
  });
186
276
  this.connected = true;
277
+ this.connectionState = 'connected';
278
+ this.lastError = undefined;
187
279
  logger.info('[WeChat] Channel connected');
188
280
  }
189
281
  async disconnect() {
@@ -192,21 +284,35 @@ export class WechatChannel {
192
284
  this.abortController.abort();
193
285
  this.abortController = undefined;
194
286
  }
287
+ const reauthRequired = this.connectionState === 'reauth_required';
288
+ if (!reauthRequired)
289
+ this.connectionState = 'disconnected';
195
290
  // 通知 ilink 后端:bot 下线
196
- try {
197
- await this.notifyLifecycle('notifystop');
198
- }
199
- catch (err) {
200
- logger.warn('[WeChat] notifyStop failed during shutdown (ignored):', err);
291
+ if (!reauthRequired) {
292
+ try {
293
+ await this.notifyLifecycle('notifystop');
294
+ }
295
+ catch (err) {
296
+ logger.warn('[WeChat] notifyStop failed during shutdown (ignored):', err);
297
+ }
201
298
  }
202
299
  logger.info('[WeChat] Channel disconnected');
203
300
  }
204
301
  /** Get current connection status */
205
302
  getStatus() {
206
- return { connected: this.connected };
303
+ return {
304
+ connected: this.connected,
305
+ state: this.connectionState,
306
+ pendingContextMessages: this.pendingContextMessages.size,
307
+ ...(this.lastError ? { error: this.lastError } : {}),
308
+ ...(this.reauthRequiredAt ? { reauthRequiredAt: this.reauthRequiredAt } : {}),
309
+ };
207
310
  }
208
311
  /** Reconnect: disconnect then connect again */
209
312
  async reconnect() {
313
+ if (this.connectionState === 'reauth_required') {
314
+ return '重连失败: 微信 token 已失效,请运行 ec init wechat 重新认证';
315
+ }
210
316
  await this.disconnect();
211
317
  try {
212
318
  await this.connect();
@@ -221,12 +327,7 @@ export class WechatChannel {
221
327
  logger.warn('[WeChat] Attempted to send empty message, skipping');
222
328
  return;
223
329
  }
224
- // Session 暂停期间拒绝发送
225
- if (this.isSessionPaused()) {
226
- const remainingMin = Math.ceil((this.sessionPausedUntil - Date.now()) / 60_000);
227
- logger.warn(`[WeChat] Session paused, ${remainingMin}min remaining, dropping outbound to ${to}`);
228
- return;
229
- }
330
+ this.assertAuthenticated();
230
331
  const contextToken = this.contextTokenCache.get(to);
231
332
  if (!contextToken) {
232
333
  logger.error(`[WeChat] No context_token for ${to}, cannot send message`);
@@ -235,21 +336,11 @@ export class WechatChannel {
235
336
  // Markdown → 纯文本
236
337
  const plainText = markdownToPlainText(text);
237
338
  const clientId = `evolcore-wechat:${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
238
- const body = {
239
- msg: {
240
- from_user_id: '',
241
- to_user_id: to,
242
- client_id: clientId,
243
- message_type: MSG_TYPE_BOT,
244
- message_state: MSG_STATE_FINISH,
245
- item_list: [{ type: MSG_ITEM_TEXT, text_item: { text: plainText } }],
246
- context_token: contextToken,
247
- },
248
- base_info: { channel_version: CHANNEL_VERSION },
249
- };
250
339
  try {
251
- await this.apiFetch('ilink/bot/sendmessage', JSON.stringify(body), DEFAULT_API_TIMEOUT_MS);
252
- logger.debug(`[WeChat] Sent message to ${to}, clientId=${clientId}`);
340
+ const delivery = await this.sendMessageItemWithContextRetry(to, { type: MSG_ITEM_TEXT, text_item: { text: plainText } }, contextToken, clientId);
341
+ if (delivery === 'sent') {
342
+ logger.debug(`[WeChat] Sent message to ${to}, clientId=${clientId}`);
343
+ }
253
344
  }
254
345
  catch (err) {
255
346
  logger.error(`[WeChat] Failed to send message to ${to}:`, err);
@@ -262,11 +353,7 @@ export class WechatChannel {
262
353
  }
263
354
  /** 发送文件(图片/视频/文件)给用户,通过 CDN 上传 */
264
355
  async sendFile(to, filePath) {
265
- // Session 暂停期间拒绝发送
266
- if (this.isSessionPaused()) {
267
- logger.warn(`[WeChat] Session paused, dropping file send to ${to}`);
268
- return;
269
- }
356
+ this.assertAuthenticated();
270
357
  const contextToken = this.contextTokenCache.get(to);
271
358
  if (!contextToken) {
272
359
  logger.error(`[WeChat] No context_token for ${to}, cannot send file`);
@@ -319,15 +406,15 @@ export class WechatChannel {
319
406
  let consecutiveFailures = 0;
320
407
  let nextTimeoutMs = DEFAULT_LONG_POLL_TIMEOUT_MS;
321
408
  logger.info('[WeChat] Starting message polling...');
322
- while (!signal.aborted) {
409
+ while (!signal.aborted && this.connectionState !== 'reauth_required') {
323
410
  try {
324
411
  const body = JSON.stringify({
325
412
  get_updates_buf: this.getUpdatesBuf,
326
- base_info: { channel_version: CHANNEL_VERSION },
413
+ base_info: buildBaseInfo(),
327
414
  });
328
- let rawText;
415
+ let resp;
329
416
  try {
330
- rawText = await this.apiFetch('ilink/bot/getupdates', body, nextTimeoutMs, signal);
417
+ resp = await this.apiRequest('ilink/bot/getupdates', body, nextTimeoutMs, signal);
331
418
  }
332
419
  catch (err) {
333
420
  if (signal.aborted)
@@ -338,93 +425,10 @@ export class WechatChannel {
338
425
  }
339
426
  throw err;
340
427
  }
341
- const resp = JSON.parse(rawText);
342
428
  // 更新服务端建议的轮询超时
343
429
  if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
344
430
  nextTimeoutMs = resp.longpolling_timeout_ms;
345
431
  }
346
- // API 错误处理
347
- const isError = (resp.ret !== undefined && resp.ret !== 0) ||
348
- (resp.errcode !== undefined && resp.errcode !== 0);
349
- if (isError) {
350
- // Session expired 专用处理
351
- const isSessionExpired = resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
352
- if (isSessionExpired) {
353
- consecutiveFailures = 0;
354
- logger.error(`[WeChat] Session expired (errcode=${resp.errcode}), retrying in ${SESSION_RETRY_DELAY_MS / 1000}s...`);
355
- // 短暂停后重试一次
356
- await this.sleep(SESSION_RETRY_DELAY_MS, signal);
357
- if (signal.aborted)
358
- return;
359
- // 重试 getupdates
360
- try {
361
- const retryBody = JSON.stringify({
362
- get_updates_buf: this.getUpdatesBuf,
363
- base_info: { channel_version: CHANNEL_VERSION },
364
- });
365
- const retryRaw = await this.apiFetch('ilink/bot/getupdates', retryBody, nextTimeoutMs, signal);
366
- const retryResp = JSON.parse(retryRaw);
367
- const retryExpired = retryResp.errcode === SESSION_EXPIRED_ERRCODE || retryResp.ret === SESSION_EXPIRED_ERRCODE;
368
- if (!retryExpired) {
369
- // 恢复成功,静默继续
370
- logger.info('[WeChat] Session recovered after retry');
371
- // 把 retryResp 当正常响应处理(更新游标和消息)
372
- if (retryResp.get_updates_buf) {
373
- this.getUpdatesBuf = retryResp.get_updates_buf;
374
- try {
375
- fs.writeFileSync(this.syncBufPath, this.getUpdatesBuf, 'utf-8');
376
- }
377
- catch { }
378
- }
379
- for (const msg of retryResp.msgs ?? []) {
380
- await this.handleInboundMessage(msg);
381
- }
382
- continue;
383
- }
384
- }
385
- catch (retryErr) {
386
- if (signal.aborted)
387
- return;
388
- logger.error('[WeChat] Retry after session expired also failed:', retryErr);
389
- }
390
- // 重试仍失败,进入长暂停
391
- const pauseMin = SESSION_PAUSE_DURATION_MS / 60_000;
392
- this.sessionPausedUntil = Date.now() + SESSION_PAUSE_DURATION_MS;
393
- logger.error(`[WeChat] Session still expired, pausing for ${pauseMin}min`);
394
- // 通知用户(通过事件总线或回调)
395
- const authMsg = `⚠️ 微信 token 已过期,通道暂停 ${pauseMin} 分钟后自动重试。\n如需立即恢复,请运行: ec init wechat`;
396
- if (this.eventBus) {
397
- this.eventBus.publish({
398
- type: 'channel:error',
399
- channel: 'wechat',
400
- status: 'auth_error',
401
- message: authMsg,
402
- timestamp: Date.now(),
403
- });
404
- }
405
- else if (this.onSessionExpired) {
406
- this.onSessionExpired(authMsg);
407
- }
408
- await this.sleep(SESSION_PAUSE_DURATION_MS, signal);
409
- if (signal.aborted)
410
- return;
411
- // 长暂停结束,清除暂停状态,循环自动重试
412
- this.sessionPausedUntil = 0;
413
- logger.info('[WeChat] Session pause ended, resuming polling');
414
- continue;
415
- }
416
- consecutiveFailures++;
417
- logger.error(`[WeChat] getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg ?? ''}`);
418
- if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
419
- logger.error(`[WeChat] ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off ${BACKOFF_DELAY_MS / 1000}s`);
420
- consecutiveFailures = 0;
421
- await this.sleep(BACKOFF_DELAY_MS, signal);
422
- }
423
- else {
424
- await this.sleep(RETRY_DELAY_MS, signal);
425
- }
426
- continue;
427
- }
428
432
  consecutiveFailures = 0;
429
433
  // 保存游标
430
434
  if (resp.get_updates_buf) {
@@ -444,8 +448,18 @@ export class WechatChannel {
444
448
  catch (err) {
445
449
  if (signal.aborted)
446
450
  return;
451
+ if (this.isSessionPaused())
452
+ return;
447
453
  consecutiveFailures++;
448
- logger.error(`[WeChat] Poll error (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}):`, err);
454
+ if (err instanceof WechatApiError) {
455
+ logger.error(`[WeChat] getUpdates API error (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ${err.message}`);
456
+ }
457
+ else {
458
+ const classified = classifyFetchError(err);
459
+ logger.error(`[WeChat] Poll network error (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ` +
460
+ `type=${classified.type} description=${classified.description}` +
461
+ `${classified.code ? ` code=${classified.code}` : ''}`, err);
462
+ }
449
463
  if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
450
464
  consecutiveFailures = 0;
451
465
  await this.sleep(BACKOFF_DELAY_MS, signal);
@@ -463,8 +477,13 @@ export class WechatChannel {
463
477
  const fromUserId = msg.from_user_id ?? '';
464
478
  // 缓存 context_token
465
479
  if (msg.context_token) {
480
+ const previousContextToken = this.contextTokenCache.get(fromUserId);
466
481
  this.contextTokenCache.set(fromUserId, msg.context_token);
467
482
  this.persistContextTokens();
483
+ if (msg.context_token !== previousContextToken) {
484
+ this.rejectedContextTokens.delete(fromUserId);
485
+ this.retryPendingContextMessage(fromUserId, msg.context_token);
486
+ }
468
487
  }
469
488
  // 提取文本(原有逻辑)
470
489
  const text = extractTextFromMessage(msg);
@@ -483,12 +502,13 @@ export class WechatChannel {
483
502
  await sendWelcomeIfNeeded(this.welcomeManager, fromUserId, fromUserId, (id, text) => this.sendMessage(id, text), 'WeChat');
484
503
  // 回调主流程
485
504
  if (this.messageHandler) {
486
- try {
487
- await this.messageHandler(fromUserId, finalContent || '', fromUserId, media.images.length ? media.images : undefined, 'private');
488
- }
489
- catch (err) {
505
+ const task = Promise.resolve()
506
+ .then(() => this.messageHandler(fromUserId, finalContent || '', fromUserId, media.images.length ? media.images : undefined, 'private'))
507
+ .catch(err => {
490
508
  logger.error('[WeChat] Message handler error:', err);
491
- }
509
+ });
510
+ this.inflightHandlers.add(task);
511
+ void task.finally(() => this.inflightHandlers.delete(task));
492
512
  }
493
513
  }
494
514
  // ── Acknowledge (sendTyping) ──────────────────────────────────────────
@@ -501,9 +521,9 @@ export class WechatChannel {
501
521
  ilink_user_id: fromUserId,
502
522
  typing_ticket: ticket,
503
523
  status: 1, // typing
504
- base_info: { channel_version: CHANNEL_VERSION },
524
+ base_info: buildBaseInfo(),
505
525
  });
506
- await this.apiFetch('ilink/bot/sendtyping', body, DEFAULT_CONFIG_TIMEOUT_MS);
526
+ await this.apiRequest('ilink/bot/sendtyping', body, DEFAULT_CONFIG_TIMEOUT_MS);
507
527
  logger.debug(`[WeChat] Sent typing indicator to ${fromUserId}`);
508
528
  }
509
529
  catch {
@@ -519,11 +539,10 @@ export class WechatChannel {
519
539
  const body = JSON.stringify({
520
540
  ilink_user_id: userId,
521
541
  context_token: contextToken,
522
- base_info: { channel_version: CHANNEL_VERSION },
542
+ base_info: buildBaseInfo(),
523
543
  });
524
- const rawText = await this.apiFetch('ilink/bot/getconfig', body, DEFAULT_CONFIG_TIMEOUT_MS);
525
- const resp = JSON.parse(rawText);
526
- if (resp.ret === 0 && resp.typing_ticket) {
544
+ const resp = await this.apiRequest('ilink/bot/getconfig', body, DEFAULT_CONFIG_TIMEOUT_MS);
545
+ if (resp.typing_ticket) {
527
546
  this.typingTicketCache.set(userId, { ticket: resp.typing_ticket, fetchedAt: Date.now() });
528
547
  return resp.typing_ticket;
529
548
  }
@@ -579,10 +598,9 @@ export class WechatChannel {
579
598
  async getUploadUrl(params) {
580
599
  const body = JSON.stringify({
581
600
  ...params,
582
- base_info: { channel_version: CHANNEL_VERSION },
601
+ base_info: buildBaseInfo(),
583
602
  });
584
- const raw = await this.apiFetch('ilink/bot/getuploadurl', body, DEFAULT_API_TIMEOUT_MS);
585
- const resp = JSON.parse(raw);
603
+ const resp = await this.apiRequest('ilink/bot/getuploadurl', body, DEFAULT_API_TIMEOUT_MS);
586
604
  if (!resp.upload_param)
587
605
  throw new Error('getuploadurl: no upload_param');
588
606
  return resp;
@@ -633,7 +651,13 @@ export class WechatChannel {
633
651
  }
634
652
  async sendMediaMessage(to, item, contextToken) {
635
653
  const clientId = `evolcore-wechat:${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
636
- const body = {
654
+ const delivery = await this.sendMessageItemWithContextRetry(to, item, contextToken, clientId);
655
+ if (delivery === 'sent') {
656
+ logger.info(`[WeChat] Sent media to ${to}, type=${item.type}`);
657
+ }
658
+ }
659
+ buildSendMessageBody(to, item, contextToken, clientId) {
660
+ return JSON.stringify({
637
661
  msg: {
638
662
  from_user_id: '',
639
663
  to_user_id: to,
@@ -643,27 +667,110 @@ export class WechatChannel {
643
667
  item_list: [item],
644
668
  context_token: contextToken,
645
669
  },
646
- base_info: { channel_version: CHANNEL_VERSION },
647
- };
648
- await this.apiFetch('ilink/bot/sendmessage', JSON.stringify(body), DEFAULT_API_TIMEOUT_MS);
649
- logger.info(`[WeChat] Sent media to ${to}, type=${item.type}`);
670
+ base_info: buildBaseInfo(),
671
+ });
672
+ }
673
+ async sendMessageItemWithContextRetry(to, item, initialContextToken, clientId) {
674
+ if (this.rejectedContextTokens.get(to) === initialContextToken) {
675
+ this.deferUntilContextTokenRefresh(to, item, clientId);
676
+ return 'deferred';
677
+ }
678
+ try {
679
+ await this.apiRequest('ilink/bot/sendmessage', this.buildSendMessageBody(to, item, initialContextToken, clientId), DEFAULT_API_TIMEOUT_MS);
680
+ return 'sent';
681
+ }
682
+ catch (err) {
683
+ if (!(err instanceof WechatApiError) ||
684
+ !err.hasCode(-2) ||
685
+ err.hasCode(SESSION_EXPIRED_ERRCODE)) {
686
+ throw err;
687
+ }
688
+ const updatedContextToken = this.contextTokenCache.get(to);
689
+ if (!updatedContextToken || updatedContextToken === initialContextToken) {
690
+ this.rejectedContextTokens.set(to, initialContextToken);
691
+ this.deferUntilContextTokenRefresh(to, item, clientId, err);
692
+ return 'deferred';
693
+ }
694
+ logger.warn(`[WeChat] sendmessage ret=-2 for ${to}; retrying once with updated context_token`);
695
+ try {
696
+ await this.apiRequest('ilink/bot/sendmessage', this.buildSendMessageBody(to, item, updatedContextToken, clientId), DEFAULT_API_TIMEOUT_MS);
697
+ return 'sent';
698
+ }
699
+ catch (retryError) {
700
+ if (retryError instanceof WechatApiError && retryError.hasCode(-2)) {
701
+ this.rejectedContextTokens.set(to, updatedContextToken);
702
+ }
703
+ throw retryError;
704
+ }
705
+ }
706
+ }
707
+ deferUntilContextTokenRefresh(to, item, clientId, cause) {
708
+ const replaced = this.pendingContextMessages.has(to);
709
+ this.pendingContextMessages.set(to, { item, clientId });
710
+ const error = new WechatContextTokenRefreshRequiredError(to, { cause });
711
+ logger.warn(`[WeChat] ${error.message}; deferred latest outbound` +
712
+ `${replaced ? ' (replaced older deferred outbound)' : ''}`);
713
+ }
714
+ retryPendingContextMessage(to, updatedContextToken) {
715
+ const pending = this.pendingContextMessages.get(to);
716
+ if (!pending)
717
+ return;
718
+ this.pendingContextMessages.delete(to);
719
+ void this.apiRequest('ilink/bot/sendmessage', this.buildSendMessageBody(to, pending.item, updatedContextToken, pending.clientId), DEFAULT_API_TIMEOUT_MS).then(() => {
720
+ logger.info(`[WeChat] Delivered deferred outbound to ${to} after context_token refresh`);
721
+ }).catch(err => {
722
+ if (err instanceof WechatApiError && err.hasCode(-2)) {
723
+ this.rejectedContextTokens.set(to, updatedContextToken);
724
+ }
725
+ logger.error(`[WeChat] Deferred outbound retry failed for ${to}:`, err);
726
+ });
650
727
  }
651
728
  // ── ilink API Helpers ─────────────────────────────────────────────────
652
729
  /** Notify ilink backend of bot lifecycle events (start/stop). */
653
730
  async notifyLifecycle(action) {
654
- const body = JSON.stringify({ base_info: { channel_version: CHANNEL_VERSION } });
655
- await this.apiFetch(`ilink/bot/msg/${action}`, body, DEFAULT_CONFIG_TIMEOUT_MS);
731
+ const body = JSON.stringify({ base_info: buildBaseInfo() });
732
+ await this.apiRequest(`ilink/bot/msg/${action}`, body, DEFAULT_CONFIG_TIMEOUT_MS);
656
733
  logger.info(`[WeChat] ${action} succeeded`);
657
734
  }
735
+ async apiRequest(endpoint, body, timeoutMs, externalSignal) {
736
+ const rawText = await this.apiFetch(endpoint, body, timeoutMs, externalSignal);
737
+ let parsed;
738
+ try {
739
+ parsed = JSON.parse(rawText);
740
+ }
741
+ catch {
742
+ throw new Error(`${endpoint} returned invalid JSON`);
743
+ }
744
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
745
+ throw new Error(`${endpoint} returned an invalid response`);
746
+ }
747
+ const resp = parsed;
748
+ const hasError = (resp.ret !== undefined && resp.ret !== 0) ||
749
+ (resp.errcode !== undefined && resp.errcode !== 0);
750
+ if (hasError) {
751
+ const apiError = new WechatApiError(endpoint, resp.ret, resp.errcode, resp.errmsg);
752
+ if (apiError.hasCode(SESSION_EXPIRED_ERRCODE)) {
753
+ this.enterReauthRequired(apiError);
754
+ }
755
+ throw apiError;
756
+ }
757
+ return resp;
758
+ }
658
759
  async apiFetch(endpoint, body, timeoutMs, externalSignal) {
760
+ this.assertAuthenticated();
659
761
  const base = this.config.baseUrl.endsWith('/') ? this.config.baseUrl : `${this.config.baseUrl}/`;
660
762
  const url = new URL(endpoint, base).toString();
661
- const headers = this.buildHeaders(body);
763
+ const headers = this.buildHeaders();
662
764
  const controller = new AbortController();
663
765
  const timer = setTimeout(() => controller.abort(), timeoutMs);
664
766
  // 外部 signal(来自 disconnect)也要能中断
665
767
  const onExternalAbort = () => controller.abort();
666
- externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
768
+ if (externalSignal?.aborted) {
769
+ controller.abort();
770
+ }
771
+ else {
772
+ externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
773
+ }
667
774
  try {
668
775
  const res = await fetch(url, {
669
776
  method: 'POST',
@@ -671,27 +778,28 @@ export class WechatChannel {
671
778
  body,
672
779
  signal: controller.signal,
673
780
  });
674
- clearTimeout(timer);
675
781
  const text = await res.text();
676
782
  if (!res.ok)
677
783
  throw new Error(`HTTP ${res.status}: ${text}`);
678
784
  return text;
679
785
  }
680
786
  catch (err) {
681
- clearTimeout(timer);
787
+ const classified = classifyFetchError(err);
788
+ logger.error(`[WeChat] API request failed: endpoint=${endpoint} type=${classified.type} ` +
789
+ `description=${classified.description}${classified.code ? ` code=${classified.code}` : ''}`);
682
790
  throw err;
683
791
  }
684
792
  finally {
793
+ clearTimeout(timer);
685
794
  externalSignal?.removeEventListener('abort', onExternalAbort);
686
795
  }
687
796
  }
688
- buildHeaders(body) {
797
+ buildHeaders() {
689
798
  const uint32 = crypto.randomBytes(4).readUInt32BE(0);
690
799
  const wechatUin = Buffer.from(String(uint32), 'utf-8').toString('base64');
691
800
  const headers = {
692
801
  'Content-Type': 'application/json',
693
802
  'AuthorizationType': 'ilink_bot_token',
694
- 'Content-Length': String(Buffer.byteLength(body, 'utf-8')),
695
803
  'X-WECHAT-UIN': wechatUin,
696
804
  'iLink-App-Id': ILINK_APP_ID,
697
805
  'iLink-App-ClientVersion': ILINK_APP_CLIENT_VERSION,
@@ -701,6 +809,42 @@ export class WechatChannel {
701
809
  }
702
810
  return headers;
703
811
  }
812
+ assertAuthenticated() {
813
+ if (this.connectionState === 'reauth_required') {
814
+ throw new Error('WeChat reauthentication required; run: ec init wechat');
815
+ }
816
+ }
817
+ enterReauthRequired(error) {
818
+ if (this.connectionState === 'reauth_required')
819
+ return;
820
+ this.connected = false;
821
+ this.connectionState = 'reauth_required';
822
+ this.lastError = error.message;
823
+ this.reauthRequiredAt = Date.now();
824
+ this.pendingContextMessages.clear();
825
+ this.rejectedContextTokens.clear();
826
+ this.abortController?.abort();
827
+ const authMsg = '微信 token 已失效,通道已停止使用旧 token。请运行: ec init wechat';
828
+ logger.error(`[WeChat] Reauthentication required: ${error.message}`);
829
+ try {
830
+ if (this.eventBus) {
831
+ this.eventBus.publish({
832
+ type: 'channel:error',
833
+ channel: 'wechat',
834
+ ...(this.channelName ? { channelName: this.channelName } : {}),
835
+ status: 'auth_error',
836
+ message: authMsg,
837
+ timestamp: Date.now(),
838
+ });
839
+ }
840
+ else if (this.onSessionExpired) {
841
+ this.onSessionExpired(authMsg);
842
+ }
843
+ }
844
+ catch (notifyError) {
845
+ logger.error('[WeChat] Failed to publish reauthentication notice:', notifyError);
846
+ }
847
+ }
704
848
  // ── Persistence ────────────────────────────────────────────────────
705
849
  /** 持久化 context_token 到文件,供 restart-monitor 等外部进程读取 */
706
850
  persistContextTokens() {
@@ -718,6 +862,10 @@ export class WechatChannel {
718
862
  // ── Utilities ─────────────────────────────────────────────────────────
719
863
  sleep(ms, signal) {
720
864
  return new Promise((resolve, reject) => {
865
+ if (signal.aborted) {
866
+ reject(new Error('aborted'));
867
+ return;
868
+ }
721
869
  const t = setTimeout(resolve, ms);
722
870
  signal.addEventListener('abort', () => {
723
871
  clearTimeout(t);
@@ -795,7 +943,7 @@ export class WechatChannelPlugin {
795
943
  return {
796
944
  channelType: 'wechat', adapter, channel,
797
945
  policy,
798
- options: { fileMarkerPattern: /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g, flushDelay: inst.flushDelay ?? DEFAULT_FLUSH_DELAY_SECONDS },
946
+ options: { fileMarkerPattern: createSendFileMarkerPattern(), flushDelay: inst.flushDelay ?? DEFAULT_FLUSH_DELAY_SECONDS },
799
947
  connect: () => channel.connect(),
800
948
  disconnect: () => channel.disconnect(),
801
949
  onProjectPathRequest: () => Promise.resolve(ctx.defaultProjectPath),