@dingxiang-me/openclaw-wechat 1.7.2 → 2.0.1

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 (65) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.en.md +379 -11
  3. package/README.md +620 -12
  4. package/docs/channels/wecom.md +181 -3
  5. package/openclaw.plugin.json +148 -5
  6. package/package.json +9 -5
  7. package/src/core/delivery-router.js +2 -0
  8. package/src/core/stream-manager.js +13 -2
  9. package/src/core.js +96 -6
  10. package/src/wecom/account-config-core.js +2 -0
  11. package/src/wecom/account-config.js +12 -3
  12. package/src/wecom/agent-context.js +7 -1
  13. package/src/wecom/agent-dispatch-executor.js +13 -1
  14. package/src/wecom/agent-dispatch-fallback.js +23 -0
  15. package/src/wecom/agent-inbound-dispatch.js +1 -1
  16. package/src/wecom/agent-inbound-processor.js +33 -2
  17. package/src/wecom/agent-late-reply-runtime.js +31 -1
  18. package/src/wecom/agent-runtime-context.js +3 -0
  19. package/src/wecom/agent-webhook-handler.js +5 -0
  20. package/src/wecom/api-client-core.js +1 -1
  21. package/src/wecom/api-client-send-text.js +43 -20
  22. package/src/wecom/bot-context.js +7 -1
  23. package/src/wecom/bot-dispatch-fallback.js +34 -3
  24. package/src/wecom/bot-dispatch-handlers.js +47 -4
  25. package/src/wecom/bot-inbound-content.js +14 -6
  26. package/src/wecom/bot-inbound-dispatch-runtime.js +10 -0
  27. package/src/wecom/bot-inbound-executor-helpers.js +44 -11
  28. package/src/wecom/bot-inbound-executor.js +40 -0
  29. package/src/wecom/bot-long-connection-manager.js +983 -0
  30. package/src/wecom/bot-reply-runtime.js +36 -6
  31. package/src/wecom/bot-runtime-context.js +3 -0
  32. package/src/wecom/bot-state-store.js +4 -5
  33. package/src/wecom/bot-webhook-dispatch.js +7 -0
  34. package/src/wecom/bot-webhook-handler.js +5 -0
  35. package/src/wecom/callback-health-diagnostics.js +86 -0
  36. package/src/wecom/channel-config-schema.js +242 -0
  37. package/src/wecom/channel-plugin.js +162 -4
  38. package/src/wecom/channel-status-state.js +150 -0
  39. package/src/wecom/command-handlers.js +6 -0
  40. package/src/wecom/command-status-text.js +32 -3
  41. package/src/wecom/doc-client.js +537 -0
  42. package/src/wecom/doc-schema.js +380 -0
  43. package/src/wecom/doc-tool.js +833 -0
  44. package/src/wecom/outbound-active-stream.js +17 -10
  45. package/src/wecom/outbound-delivery.js +46 -0
  46. package/src/wecom/outbound-webhook-sender.js +39 -16
  47. package/src/wecom/plugin-account-policy-services.js +4 -1
  48. package/src/wecom/plugin-composition.js +2 -0
  49. package/src/wecom/plugin-constants.js +1 -1
  50. package/src/wecom/plugin-delivery-inbound-services.js +4 -0
  51. package/src/wecom/plugin-processing-deps.js +5 -0
  52. package/src/wecom/plugin-route-runtime-deps.js +2 -0
  53. package/src/wecom/plugin-services.js +37 -0
  54. package/src/wecom/register-runtime.js +20 -1
  55. package/src/wecom/request-parsers.js +1 -0
  56. package/src/wecom/route-registration.js +4 -1
  57. package/src/wecom/session-reset.js +168 -0
  58. package/src/wecom/target-utils.js +41 -5
  59. package/src/wecom/text-format.js +22 -5
  60. package/src/wecom/text-inbound-scheduler.js +1 -1
  61. package/src/wecom/thinking-parser.js +74 -0
  62. package/src/wecom/voice-transcription-process.js +145 -11
  63. package/src/wecom/voice-transcription.js +14 -2
  64. package/src/wecom/webhook-adapter-normalize.js +29 -0
  65. package/src/wecom/webhook-adapter.js +294 -59
@@ -0,0 +1,983 @@
1
+ import crypto from "node:crypto";
2
+ import { HttpsProxyAgent } from "https-proxy-agent";
3
+ import WebSocket from "ws";
4
+ import { markWecomInboundActivity, setWecomConnectionState } from "./channel-status-state.js";
5
+
6
+ const DEFAULT_LONG_CONNECTION_URL = "wss://openws.work.weixin.qq.com";
7
+ const LEGACY_LONG_CONNECTION_URL = "wss://open.work.weixin.qq.com/ws/aibot";
8
+ const DEFAULT_CONTEXT_TTL_MS = 60 * 60 * 1000;
9
+ const DEFAULT_REPLY_ACK_TIMEOUT_MS = 5000;
10
+ const LONG_CONNECTION_RUNTIME_MARKER = "openclaw-wechat-longconn-2026-03-08";
11
+ const CMD_SUBSCRIBE = "aibot_subscribe";
12
+ const CMD_PING = "ping";
13
+ const CMD_RESPONSE = "aibot_respond_msg";
14
+ const CMD_CALLBACK = "aibot_msg_callback";
15
+ const CMD_EVENT_CALLBACK = "aibot_event_callback";
16
+
17
+ function assertFunction(name, value) {
18
+ if (typeof value !== "function") {
19
+ throw new Error(`createWecomBotLongConnectionManager: ${name} is required`);
20
+ }
21
+ }
22
+
23
+ function normalizeAccountId(accountId) {
24
+ const normalized = String(accountId ?? "default").trim().toLowerCase();
25
+ return normalized || "default";
26
+ }
27
+
28
+ function normalizeLongConnectionUrl(value) {
29
+ const normalized = String(value ?? "").trim();
30
+ if (!normalized) return DEFAULT_LONG_CONNECTION_URL;
31
+ if (normalized === LEGACY_LONG_CONNECTION_URL) return DEFAULT_LONG_CONNECTION_URL;
32
+ return normalized;
33
+ }
34
+
35
+ function isLikelyHttpProxyUrl(proxyUrl) {
36
+ return /^https?:\/\/\S+$/i.test(String(proxyUrl ?? "").trim());
37
+ }
38
+
39
+ function sanitizeProxyForLog(proxyUrl) {
40
+ const raw = String(proxyUrl ?? "").trim();
41
+ if (!raw) return "";
42
+ try {
43
+ const parsed = new URL(raw);
44
+ if (parsed.username || parsed.password) {
45
+ parsed.username = "***";
46
+ parsed.password = "***";
47
+ }
48
+ return parsed.toString();
49
+ } catch {
50
+ return raw;
51
+ }
52
+ }
53
+
54
+ function normalizeReplyContext(context = {}) {
55
+ const accountId = normalizeAccountId(context.accountId);
56
+ const sessionId = String(context.sessionId ?? "").trim();
57
+ const streamId = String(context.streamId ?? "").trim();
58
+ const msgId = String(context.msgId ?? "").trim();
59
+ const reqId = String(context.reqId ?? "").trim();
60
+ if (!accountId || !sessionId || !streamId || !msgId || !reqId) return null;
61
+ return {
62
+ accountId,
63
+ sessionId,
64
+ streamId,
65
+ msgId,
66
+ reqId,
67
+ fromUser: String(context.fromUser ?? "").trim(),
68
+ chatId: String(context.chatId ?? "").trim(),
69
+ expiresAt: Date.now() + DEFAULT_CONTEXT_TTL_MS,
70
+ updatedAt: Date.now(),
71
+ };
72
+ }
73
+
74
+ async function readWebSocketMessageData(data) {
75
+ const source = data && typeof data === "object" && "data" in data ? data.data : data;
76
+ if (typeof source === "string") return source;
77
+ if (source instanceof Uint8Array || Buffer.isBuffer(source)) {
78
+ return Buffer.from(source).toString("utf8");
79
+ }
80
+ if (source instanceof ArrayBuffer) {
81
+ return Buffer.from(source).toString("utf8");
82
+ }
83
+ if (source && typeof source.text === "function") {
84
+ return await source.text();
85
+ }
86
+ return String(source ?? "");
87
+ }
88
+
89
+ function bindSocketListener(ws, type, handler) {
90
+ if (!ws || typeof handler !== "function") return;
91
+ if (typeof ws.addEventListener === "function") {
92
+ ws.addEventListener(type, handler);
93
+ return;
94
+ }
95
+ if (typeof ws.on === "function") {
96
+ ws.on(type, handler);
97
+ return;
98
+ }
99
+ throw new Error(`Unsupported WebSocket listener API for event: ${type}`);
100
+ }
101
+
102
+ function safeCloseSocket(ws, code = 1000, reason = "") {
103
+ if (!ws) return;
104
+ try {
105
+ ws.close?.(code, reason);
106
+ } catch {
107
+ // ignore close failure
108
+ }
109
+ }
110
+
111
+ function safeTerminateSocket(ws) {
112
+ if (!ws) return;
113
+ try {
114
+ if (typeof ws.terminate === "function") {
115
+ ws.terminate();
116
+ return;
117
+ }
118
+ } catch {
119
+ // ignore terminate failure
120
+ }
121
+ safeCloseSocket(ws, 1000, "terminated");
122
+ }
123
+
124
+ function socketOpenState(webSocketCtor) {
125
+ return Number(webSocketCtor?.OPEN ?? 1);
126
+ }
127
+
128
+ function isSocketOpen(ws, webSocketCtor) {
129
+ return Boolean(ws) && Number(ws.readyState) === socketOpenState(webSocketCtor);
130
+ }
131
+
132
+ function normalizeCloseReason(reason) {
133
+ if (reason == null) return "";
134
+ if (typeof reason === "string") return reason;
135
+ if (reason instanceof Uint8Array || Buffer.isBuffer(reason)) return Buffer.from(reason).toString("utf8");
136
+ return String(reason);
137
+ }
138
+
139
+ export function createWecomBotLongConnectionManager({
140
+ attachWecomProxyDispatcher,
141
+ resolveWecomBotConfigs,
142
+ resolveWecomBotProxyConfig,
143
+ parseWecomBotInboundMessage,
144
+ describeWecomBotParsedMessage,
145
+ buildWecomBotSessionId,
146
+ createBotStream,
147
+ upsertBotResponseUrlCache,
148
+ markInboundMessageSeen,
149
+ messageProcessLimiter,
150
+ executeInboundTaskWithSessionQueue,
151
+ deliverBotReplyText,
152
+ recordInboundMetric = () => {},
153
+ recordRuntimeErrorMetric = () => {},
154
+ webSocketCtor = WebSocket,
155
+ wsProxyAgentCtor = HttpsProxyAgent,
156
+ setTimeoutFn = setTimeout,
157
+ clearTimeoutFn = clearTimeout,
158
+ setIntervalFn = setInterval,
159
+ clearIntervalFn = clearInterval,
160
+ randomUuid = () => crypto.randomUUID?.(),
161
+ } = {}) {
162
+ assertFunction("attachWecomProxyDispatcher", attachWecomProxyDispatcher);
163
+ assertFunction("resolveWecomBotConfigs", resolveWecomBotConfigs);
164
+ assertFunction("resolveWecomBotProxyConfig", resolveWecomBotProxyConfig);
165
+ assertFunction("parseWecomBotInboundMessage", parseWecomBotInboundMessage);
166
+ assertFunction("describeWecomBotParsedMessage", describeWecomBotParsedMessage);
167
+ assertFunction("buildWecomBotSessionId", buildWecomBotSessionId);
168
+ assertFunction("createBotStream", createBotStream);
169
+ assertFunction("upsertBotResponseUrlCache", upsertBotResponseUrlCache);
170
+ assertFunction("markInboundMessageSeen", markInboundMessageSeen);
171
+ if (!messageProcessLimiter || typeof messageProcessLimiter.execute !== "function") {
172
+ throw new Error("createWecomBotLongConnectionManager: messageProcessLimiter.execute is required");
173
+ }
174
+ assertFunction("executeInboundTaskWithSessionQueue", executeInboundTaskWithSessionQueue);
175
+ assertFunction("deliverBotReplyText", deliverBotReplyText);
176
+ if (typeof webSocketCtor !== "function") {
177
+ throw new Error("createWecomBotLongConnectionManager: webSocketCtor is required");
178
+ }
179
+ if (typeof wsProxyAgentCtor !== "function") {
180
+ throw new Error("createWecomBotLongConnectionManager: wsProxyAgentCtor is required");
181
+ }
182
+
183
+ let processBotInboundMessage = null;
184
+ const clients = new Map();
185
+ const streamContexts = new Map();
186
+ const sessionContexts = new Map();
187
+ const wsProxyAgentCache = new Map();
188
+ const invalidProxyCache = new Set();
189
+
190
+ function setProcessBotInboundHandler(handler) {
191
+ processBotInboundMessage = typeof handler === "function" ? handler : null;
192
+ }
193
+
194
+ function pruneReplyContexts() {
195
+ const now = Date.now();
196
+ for (const [streamId, context] of streamContexts.entries()) {
197
+ if (Number(context?.expiresAt ?? 0) <= now) {
198
+ streamContexts.delete(streamId);
199
+ }
200
+ }
201
+ for (const [sessionId, context] of sessionContexts.entries()) {
202
+ if (Number(context?.expiresAt ?? 0) <= now) {
203
+ sessionContexts.delete(sessionId);
204
+ }
205
+ }
206
+ }
207
+
208
+ function rememberReplyContext(context = {}) {
209
+ const normalized = normalizeReplyContext(context);
210
+ if (!normalized) return null;
211
+ streamContexts.set(normalized.streamId, normalized);
212
+ sessionContexts.set(normalized.sessionId, normalized);
213
+ return normalized;
214
+ }
215
+
216
+ function resolveReplyContext({ accountId = "default", streamId = "", sessionId = "" } = {}) {
217
+ pruneReplyContexts();
218
+ const normalizedStreamId = String(streamId ?? "").trim();
219
+ const normalizedSessionId = String(sessionId ?? "").trim();
220
+ const normalizedAccountId = normalizeAccountId(accountId);
221
+ const match =
222
+ (normalizedStreamId ? streamContexts.get(normalizedStreamId) : null) ??
223
+ (normalizedSessionId ? sessionContexts.get(normalizedSessionId) : null) ??
224
+ null;
225
+ if (!match) return null;
226
+ if (normalizeAccountId(match.accountId) !== normalizedAccountId) return null;
227
+ return match;
228
+ }
229
+
230
+ function buildStreamId(accountId = "default") {
231
+ const normalized = String(randomUuid() || "").trim();
232
+ const accountSlug = normalizeAccountId(accountId).replace(/[^a-z0-9_-]/g, "_") || "default";
233
+ if (normalized) return `stream_${normalized}`;
234
+ return `stream_${accountSlug}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
235
+ }
236
+
237
+ function buildRequestId(prefix = "req") {
238
+ const normalizedPrefix = String(prefix ?? "req")
239
+ .trim()
240
+ .replace(/[^a-z0-9_-]/gi, "_") || "req";
241
+ const normalized = String(randomUuid() || "").trim();
242
+ if (normalized) return `${normalizedPrefix}_${normalized}`;
243
+ return `${normalizedPrefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
244
+ }
245
+
246
+ function resolveWebSocketOptions(client, api) {
247
+ const proxyUrl = String(client?.proxyUrl ?? "").trim();
248
+ if (!proxyUrl) return undefined;
249
+ const printableProxy = sanitizeProxyForLog(proxyUrl);
250
+ if (!isLikelyHttpProxyUrl(proxyUrl)) {
251
+ if (!invalidProxyCache.has(proxyUrl)) {
252
+ invalidProxyCache.add(proxyUrl);
253
+ api?.logger?.warn?.(
254
+ `wecom(bot-longconn): proxy ignored for websocket account=${client.accountId} proxy=${printableProxy}`,
255
+ );
256
+ }
257
+ return undefined;
258
+ }
259
+ let agent = wsProxyAgentCache.get(proxyUrl);
260
+ if (!agent) {
261
+ agent = new wsProxyAgentCtor(proxyUrl);
262
+ wsProxyAgentCache.set(proxyUrl, agent);
263
+ api?.logger?.info?.(
264
+ `wecom(bot-longconn): websocket proxy enabled account=${client.accountId} proxy=${printableProxy}`,
265
+ );
266
+ }
267
+ return { agent };
268
+ }
269
+
270
+ function getClient(accountId = "default") {
271
+ return clients.get(normalizeAccountId(accountId)) ?? null;
272
+ }
273
+
274
+ function sendRawJson(client, payload) {
275
+ if (!client?.ws || !isSocketOpen(client.ws, webSocketCtor)) {
276
+ return false;
277
+ }
278
+ client.ws.send(JSON.stringify(payload));
279
+ client.lastSentAt = Date.now();
280
+ return true;
281
+ }
282
+
283
+ function sendCommand(client, { cmd = "", body, reqId = "", reqIdPrefix = cmd, headers = {} } = {}) {
284
+ const normalizedCmd = String(cmd ?? "").trim();
285
+ if (!normalizedCmd) return "";
286
+ const resolvedReqId = String(reqId ?? "").trim() || buildRequestId(reqIdPrefix || normalizedCmd);
287
+ const payload = {
288
+ cmd: normalizedCmd,
289
+ headers: {
290
+ ...headers,
291
+ req_id: resolvedReqId,
292
+ },
293
+ };
294
+ if (body !== undefined) {
295
+ payload.body = body;
296
+ }
297
+ const sent = sendRawJson(client, payload);
298
+ return sent ? resolvedReqId : "";
299
+ }
300
+
301
+ function clearClientTimers(client) {
302
+ if (!client) return;
303
+ if (client.pingTimer) {
304
+ clearIntervalFn(client.pingTimer);
305
+ client.pingTimer = null;
306
+ }
307
+ if (client.reconnectTimer) {
308
+ clearTimeoutFn(client.reconnectTimer);
309
+ client.reconnectTimer = null;
310
+ }
311
+ }
312
+
313
+ function clearPendingReplies(client, reason = "connection reset") {
314
+ if (!client) return;
315
+ for (const pending of client.pendingAcks.values()) {
316
+ clearTimeoutFn(pending.timer);
317
+ pending.reject(new Error(reason));
318
+ }
319
+ client.pendingAcks.clear();
320
+ for (const [reqId, queue] of client.replyQueues.entries()) {
321
+ for (const item of queue) {
322
+ item.reject(new Error(`${reason} (${reqId})`));
323
+ }
324
+ }
325
+ client.replyQueues.clear();
326
+ }
327
+
328
+ function handleReplyAck(client, reqId, frame) {
329
+ const pending = client?.pendingAcks?.get(reqId);
330
+ if (!pending) return;
331
+ clearTimeoutFn(pending.timer);
332
+ client.pendingAcks.delete(reqId);
333
+ const queue = client.replyQueues.get(reqId);
334
+ if (queue) {
335
+ queue.shift();
336
+ if (queue.length === 0) {
337
+ client.replyQueues.delete(reqId);
338
+ }
339
+ }
340
+ if (Number(frame?.errcode ?? -1) === 0) {
341
+ pending.resolve(frame);
342
+ } else {
343
+ pending.reject(
344
+ new Error(
345
+ `reply rejected errcode=${Number(frame?.errcode ?? -1)} errmsg=${String(frame?.errmsg ?? "unknown")}`,
346
+ ),
347
+ );
348
+ }
349
+ if (client.replyQueues.has(reqId)) {
350
+ processReplyQueue(client, reqId);
351
+ }
352
+ }
353
+
354
+ function processReplyQueue(client, reqId) {
355
+ if (!client || !reqId) return;
356
+ if (client.pendingAcks.has(reqId)) return;
357
+ const queue = client.replyQueues.get(reqId);
358
+ if (!Array.isArray(queue) || queue.length === 0) {
359
+ client.replyQueues.delete(reqId);
360
+ return;
361
+ }
362
+ const item = queue[0];
363
+ if (!client.connected || !client.ws || !isSocketOpen(client.ws, webSocketCtor)) {
364
+ queue.shift();
365
+ item.reject(new Error("long connection is not ready"));
366
+ if (queue.length === 0) {
367
+ client.replyQueues.delete(reqId);
368
+ } else {
369
+ processReplyQueue(client, reqId);
370
+ }
371
+ return;
372
+ }
373
+ const sent = sendRawJson(client, item.frame);
374
+ if (!sent) {
375
+ queue.shift();
376
+ item.reject(new Error("failed to send long connection frame"));
377
+ if (queue.length === 0) {
378
+ client.replyQueues.delete(reqId);
379
+ } else {
380
+ processReplyQueue(client, reqId);
381
+ }
382
+ return;
383
+ }
384
+ const timer = setTimeoutFn(() => {
385
+ client.pendingAcks.delete(reqId);
386
+ const currentQueue = client.replyQueues.get(reqId);
387
+ if (currentQueue?.length) {
388
+ currentQueue.shift();
389
+ if (currentQueue.length === 0) {
390
+ client.replyQueues.delete(reqId);
391
+ }
392
+ }
393
+ item.reject(new Error(`reply ack timeout (${DEFAULT_REPLY_ACK_TIMEOUT_MS}ms)`));
394
+ if (client.replyQueues.has(reqId)) {
395
+ processReplyQueue(client, reqId);
396
+ }
397
+ }, DEFAULT_REPLY_ACK_TIMEOUT_MS);
398
+ timer?.unref?.();
399
+ client.pendingAcks.set(reqId, {
400
+ timer,
401
+ resolve: item.resolve,
402
+ reject: item.reject,
403
+ });
404
+ }
405
+
406
+ function enqueueReplyFrame(client, { reqId = "", cmd = CMD_RESPONSE, body } = {}) {
407
+ const normalizedReqId = String(reqId ?? "").trim();
408
+ if (!normalizedReqId) {
409
+ return Promise.reject(new Error("missing req_id for long connection reply"));
410
+ }
411
+ const payload = {
412
+ cmd,
413
+ headers: {
414
+ req_id: normalizedReqId,
415
+ },
416
+ body,
417
+ };
418
+ return new Promise((resolve, reject) => {
419
+ const queue = client.replyQueues.get(normalizedReqId) ?? [];
420
+ queue.push({ frame: payload, resolve, reject });
421
+ client.replyQueues.set(normalizedReqId, queue);
422
+ if (queue.length === 1) {
423
+ processReplyQueue(client, normalizedReqId);
424
+ }
425
+ });
426
+ }
427
+
428
+ function startPingLoop(client, api) {
429
+ if (client.pingTimer) {
430
+ clearIntervalFn(client.pingTimer);
431
+ client.pingTimer = null;
432
+ }
433
+ const intervalMs = Math.max(10000, Number(client?.config?.longConnection?.pingIntervalMs) || 30000);
434
+ client.missedHeartbeatAcks = 0;
435
+ client.pingTimer = setIntervalFn(() => {
436
+ try {
437
+ if (client.missedHeartbeatAcks >= 2) {
438
+ api?.logger?.warn?.(
439
+ `wecom(bot-longconn): heartbeat missed twice, force reconnect account=${client.accountId}`,
440
+ );
441
+ safeTerminateSocket(client.ws);
442
+ return;
443
+ }
444
+ client.missedHeartbeatAcks += 1;
445
+ client.lastPingReqId =
446
+ sendCommand(client, {
447
+ cmd: CMD_PING,
448
+ reqIdPrefix: CMD_PING,
449
+ }) || client.lastPingReqId;
450
+ } catch (err) {
451
+ api?.logger?.warn?.(
452
+ `wecom(bot-longconn): ping failed account=${client.accountId}: ${String(err?.message || err)}`,
453
+ );
454
+ }
455
+ }, intervalMs);
456
+ client.pingTimer?.unref?.();
457
+ }
458
+
459
+ function scheduleReconnect(client, api) {
460
+ if (!client?.shouldRun) return;
461
+ if (client.reconnectTimer) return;
462
+ const baseDelay = Math.max(1000, Number(client?.config?.longConnection?.reconnectDelayMs) || 5000);
463
+ const maxDelay = Math.max(baseDelay, Number(client?.config?.longConnection?.maxReconnectDelayMs) || 60000);
464
+ const delayMs = Math.min(baseDelay * Math.pow(2, Math.max(0, client.reconnectAttempts || 0)), maxDelay);
465
+ client.reconnectAttempts = Math.max(0, client.reconnectAttempts || 0) + 1;
466
+ client.reconnectTimer = setTimeoutFn(() => {
467
+ client.reconnectTimer = null;
468
+ connectClient(client, api);
469
+ }, delayMs);
470
+ client.reconnectTimer?.unref?.();
471
+ api?.logger?.warn?.(
472
+ `wecom(bot-longconn): reconnect scheduled account=${client.accountId} in ${delayMs}ms`,
473
+ );
474
+ }
475
+
476
+ function stopClient(client, { closeCode = 1000, reason = "stopped" } = {}) {
477
+ if (!client) return;
478
+ client.shouldRun = false;
479
+ clearClientTimers(client);
480
+ clearPendingReplies(client, `long connection stopped: ${reason}`);
481
+ client.connected = false;
482
+ client.socketOpen = false;
483
+ client.subscribeReqId = "";
484
+ client.lastPingReqId = "";
485
+ client.missedHeartbeatAcks = 0;
486
+ setWecomConnectionState({
487
+ accountId: client.accountId,
488
+ connected: false,
489
+ transport: "bot.longConnection",
490
+ });
491
+ safeCloseSocket(client.ws, closeCode, reason);
492
+ client.ws = null;
493
+ }
494
+
495
+ async function scheduleInboundTask({ api, parsed, botSessionId, streamId }) {
496
+ if (typeof processBotInboundMessage !== "function") {
497
+ throw new Error("bot long connection inbound processor not configured");
498
+ }
499
+ messageProcessLimiter
500
+ .execute(() =>
501
+ executeInboundTaskWithSessionQueue({
502
+ api,
503
+ sessionId: botSessionId,
504
+ isBot: true,
505
+ task: () =>
506
+ processBotInboundMessage({
507
+ api,
508
+ streamId,
509
+ fromUser: parsed.fromUser,
510
+ content: parsed.content,
511
+ msgType: parsed.msgType,
512
+ msgId: parsed.msgId,
513
+ chatId: parsed.chatId,
514
+ isGroupChat: parsed.isGroupChat,
515
+ imageUrls: parsed.imageUrls,
516
+ imageEntries: parsed.imageEntries,
517
+ fileUrl: parsed.fileUrl,
518
+ fileName: parsed.fileName,
519
+ fileAesKey: parsed.fileAesKey,
520
+ quote: parsed.quote,
521
+ responseUrl: parsed.responseUrl,
522
+ accountId: parsed.accountId,
523
+ voiceUrl: parsed.voiceUrl,
524
+ voiceMediaId: parsed.voiceMediaId,
525
+ voiceContentType: parsed.voiceContentType,
526
+ }),
527
+ }),
528
+ )
529
+ .catch((err) => {
530
+ api?.logger?.error?.(
531
+ `wecom(bot-longconn): async message processing failed: ${String(err?.message || err)}`,
532
+ );
533
+ recordRuntimeErrorMetric({
534
+ scope: "bot-longconn-dispatch",
535
+ reason: String(err?.message || err),
536
+ accountId: parsed.accountId,
537
+ });
538
+ deliverBotReplyText({
539
+ api,
540
+ fromUser: parsed.fromUser,
541
+ sessionId: botSessionId,
542
+ streamId,
543
+ accountId: parsed.accountId,
544
+ text: `抱歉,当前模型请求失败,请稍后重试。\n故障信息: ${String(err?.message || err).slice(0, 160)}`,
545
+ reason: "bot-longconn-processing-error",
546
+ }).catch((deliveryErr) => {
547
+ api?.logger?.warn?.(
548
+ `wecom(bot-longconn): failed to deliver async error reply: ${String(deliveryErr?.message || deliveryErr)}`,
549
+ );
550
+ });
551
+ });
552
+ }
553
+
554
+ async function pushStreamUpdate({
555
+ accountId = "default",
556
+ sessionId = "",
557
+ streamId = "",
558
+ content = "",
559
+ finish = false,
560
+ msgItem,
561
+ thinkingContent = "",
562
+ } = {}) {
563
+ const context = resolveReplyContext({ accountId, streamId, sessionId });
564
+ if (!context) return { ok: false, reason: "context-missing" };
565
+ const client = getClient(context.accountId);
566
+ if (!client || client.connected !== true) {
567
+ return { ok: false, reason: "connection-missing" };
568
+ }
569
+
570
+ const body = {
571
+ msgtype: "stream",
572
+ stream: {
573
+ id: String(streamId || context.streamId),
574
+ content: String(content ?? ""),
575
+ finish: finish === true,
576
+ },
577
+ };
578
+ if (Array.isArray(msgItem) && msgItem.length > 0) {
579
+ body.stream.msg_item = msgItem;
580
+ }
581
+ if (String(thinkingContent ?? "").trim()) {
582
+ body.stream.thinking_content = String(thinkingContent).trim();
583
+ }
584
+
585
+ await enqueueReplyFrame(client, {
586
+ reqId: context.reqId,
587
+ cmd: CMD_RESPONSE,
588
+ body,
589
+ });
590
+
591
+ if (finish === true) {
592
+ const nextContext = {
593
+ ...context,
594
+ expiresAt: Date.now() + DEFAULT_CONTEXT_TTL_MS,
595
+ updatedAt: Date.now(),
596
+ };
597
+ streamContexts.set(context.streamId, nextContext);
598
+ sessionContexts.set(context.sessionId, nextContext);
599
+ }
600
+ return { ok: true, mode: "long_connection", msgId: context.msgId };
601
+ }
602
+
603
+ async function handleParsedMessage({ api, client, parsed }) {
604
+ if (!parsed || typeof parsed !== "object") return;
605
+ parsed.accountId = client.accountId;
606
+ markWecomInboundActivity({
607
+ accountId: client.accountId,
608
+ timestamp: Date.now(),
609
+ });
610
+ api?.logger?.info?.(
611
+ `wecom(bot-longconn): inbound ${describeWecomBotParsedMessage(parsed)} account=${client.accountId}`,
612
+ );
613
+
614
+ if (parsed.kind === "event" || parsed.kind === "unsupported" || parsed.kind === "invalid") {
615
+ return;
616
+ }
617
+ if (parsed.kind !== "message") return;
618
+
619
+ recordInboundMetric({
620
+ mode: "bot-longconn",
621
+ msgType: parsed.msgType || parsed.kind || "unknown",
622
+ accountId: client.accountId,
623
+ });
624
+ const dedupeStub = {
625
+ MsgId: parsed.msgId,
626
+ FromUserName: parsed.fromUser,
627
+ MsgType: parsed.msgType,
628
+ Content: parsed.content,
629
+ CreateTime: String(Math.floor(Date.now() / 1000)),
630
+ };
631
+ if (!markInboundMessageSeen(dedupeStub, `bot:${client.accountId}`)) {
632
+ return;
633
+ }
634
+
635
+ const botSessionId = buildWecomBotSessionId(parsed.fromUser, client.accountId);
636
+ const streamId = buildStreamId(client.accountId);
637
+ createBotStream(streamId, client.config.placeholderText, {
638
+ feedbackId: parsed.feedbackId,
639
+ sessionId: botSessionId,
640
+ accountId: client.accountId,
641
+ });
642
+ if (parsed.responseUrl) {
643
+ upsertBotResponseUrlCache({
644
+ sessionId: botSessionId,
645
+ responseUrl: parsed.responseUrl,
646
+ });
647
+ }
648
+ rememberReplyContext({
649
+ accountId: client.accountId,
650
+ sessionId: botSessionId,
651
+ streamId,
652
+ msgId: parsed.msgId,
653
+ reqId: parsed.reqId,
654
+ fromUser: parsed.fromUser,
655
+ chatId: parsed.chatId,
656
+ });
657
+ void pushStreamUpdate({
658
+ accountId: client.accountId,
659
+ sessionId: botSessionId,
660
+ streamId,
661
+ content: client.config.placeholderText,
662
+ finish: false,
663
+ }).catch((err) => {
664
+ api?.logger?.warn?.(
665
+ `wecom(bot-longconn): placeholder push failed account=${client.accountId}: ${String(err?.message || err)}`,
666
+ );
667
+ });
668
+ await scheduleInboundTask({
669
+ api,
670
+ parsed,
671
+ botSessionId,
672
+ streamId,
673
+ });
674
+ }
675
+
676
+ async function handleSocketFrame(client, api, payload) {
677
+ const command = String(payload?.cmd ?? "").trim().toLowerCase();
678
+ const reqId = String(payload?.headers?.req_id ?? payload?.headers?.reqId ?? "").trim();
679
+
680
+ if (reqId && client.pendingAcks.has(reqId)) {
681
+ handleReplyAck(client, reqId, payload);
682
+ return;
683
+ }
684
+
685
+ if (command === "pong") {
686
+ client.missedHeartbeatAcks = 0;
687
+ return;
688
+ }
689
+
690
+ if (command === CMD_CALLBACK || command === CMD_EVENT_CALLBACK) {
691
+ const normalizedBody =
692
+ command === CMD_EVENT_CALLBACK && payload?.body && typeof payload.body === "object" && !payload.body.msgtype
693
+ ? { ...payload.body, msgtype: "event" }
694
+ : payload?.body;
695
+ const parsed = parseWecomBotInboundMessage(normalizedBody);
696
+ if (!parsed) {
697
+ const bodyKeys =
698
+ normalizedBody && typeof normalizedBody === "object"
699
+ ? Object.keys(normalizedBody).slice(0, 12).join(",")
700
+ : "non-object";
701
+ api?.logger?.warn?.(
702
+ `wecom(bot-longconn): ignored unparsed callback account=${client.accountId} cmd=${command} bodyKeys=${bodyKeys || "n/a"}`,
703
+ );
704
+ return;
705
+ }
706
+ if (parsed && typeof parsed === "object") {
707
+ parsed.reqId = reqId || buildRequestId(CMD_CALLBACK);
708
+ }
709
+ await handleParsedMessage({
710
+ api,
711
+ client,
712
+ parsed,
713
+ });
714
+ return;
715
+ }
716
+
717
+ if (payload && typeof payload === "object" && Object.hasOwn(payload, "errcode")) {
718
+ const errcode = Number(payload?.errcode ?? -1);
719
+ const errmsg = String(payload?.errmsg ?? "").trim() || "n/a";
720
+ if (reqId && (reqId === client.subscribeReqId || reqId.startsWith(`${CMD_SUBSCRIBE}_`))) {
721
+ if (errcode === 0) {
722
+ client.connected = true;
723
+ client.socketOpen = true;
724
+ client.reconnectAttempts = 0;
725
+ client.missedHeartbeatAcks = 0;
726
+ setWecomConnectionState({
727
+ accountId: client.accountId,
728
+ connected: true,
729
+ transport: "bot.longConnection",
730
+ });
731
+ startPingLoop(client, api);
732
+ api?.logger?.info?.(`wecom(bot-longconn): subscribed account=${client.accountId}`);
733
+ } else {
734
+ api?.logger?.warn?.(
735
+ `wecom(bot-longconn): subscribe failed account=${client.accountId} errcode=${errcode} errmsg=${errmsg}`,
736
+ );
737
+ safeCloseSocket(client.ws, 4001, `subscribe failed: ${errmsg}`);
738
+ }
739
+ return;
740
+ }
741
+ if (reqId && (reqId === client.lastPingReqId || reqId.startsWith(`${CMD_PING}_`))) {
742
+ if (errcode !== 0) {
743
+ api?.logger?.warn?.(
744
+ `wecom(bot-longconn): ping rejected account=${client.accountId} errcode=${errcode} errmsg=${errmsg}`,
745
+ );
746
+ return;
747
+ }
748
+ client.missedHeartbeatAcks = 0;
749
+ return;
750
+ }
751
+ if (errcode !== 0) {
752
+ api?.logger?.warn?.(
753
+ `wecom(bot-longconn): command rejected account=${client.accountId} reqId=${reqId || "n/a"} errcode=${errcode} errmsg=${errmsg}`,
754
+ );
755
+ }
756
+ return;
757
+ }
758
+
759
+ if (command && command !== CMD_PING) {
760
+ api?.logger?.debug?.(
761
+ `wecom(bot-longconn): ignore message cmd=${command} account=${client.accountId}`,
762
+ );
763
+ }
764
+ }
765
+
766
+ async function handleSocketMessage(client, api, eventOrData) {
767
+ try {
768
+ const raw = await readWebSocketMessageData(eventOrData);
769
+ const payload = JSON.parse(String(raw ?? "{}"));
770
+ await handleSocketFrame(client, api, payload);
771
+ } catch (err) {
772
+ api?.logger?.warn?.(
773
+ `wecom(bot-longconn): failed to handle socket message account=${client.accountId}: ${String(err?.message || err)}`,
774
+ );
775
+ recordRuntimeErrorMetric({
776
+ scope: "bot-longconn-message",
777
+ reason: String(err?.message || err),
778
+ accountId: client.accountId,
779
+ });
780
+ }
781
+ }
782
+
783
+ function connectClient(client, api) {
784
+ if (!client?.shouldRun) return;
785
+ clearClientTimers(client);
786
+ clearPendingReplies(client, "reconnecting");
787
+ setWecomConnectionState({
788
+ accountId: client.accountId,
789
+ connected: false,
790
+ transport: "bot.longConnection",
791
+ });
792
+ const wsUrl = normalizeLongConnectionUrl(client?.config?.longConnection?.url ?? DEFAULT_LONG_CONNECTION_URL);
793
+ const proxyUrl = String(client?.proxyUrl ?? "").trim();
794
+ let wsOptions;
795
+ try {
796
+ wsOptions = resolveWebSocketOptions(client, api);
797
+ } catch (err) {
798
+ api?.logger?.warn?.(
799
+ `wecom(bot-longconn): websocket proxy init failed account=${client.accountId}: ${String(err?.message || err)}`,
800
+ );
801
+ }
802
+ api?.logger?.info?.(
803
+ `wecom(bot-longconn): connect attempt account=${client.accountId} marker=${LONG_CONNECTION_RUNTIME_MARKER} url=${wsUrl} proxy=${proxyUrl || "direct"} wsCtor=${String(webSocketCtor?.name || "unknown")}`,
804
+ );
805
+ client.ws = new webSocketCtor(wsUrl, [], wsOptions);
806
+ client.connected = false;
807
+ client.socketOpen = false;
808
+ client.subscribeReqId = "";
809
+ client.lastPingReqId = "";
810
+ client.missedHeartbeatAcks = 0;
811
+ client.lastConnectStartedAt = Date.now();
812
+
813
+ bindSocketListener(client.ws, "open", () => {
814
+ client.socketOpen = true;
815
+ client.reconnectAttempts = 0;
816
+ client.subscribeReqId =
817
+ sendCommand(client, {
818
+ cmd: CMD_SUBSCRIBE,
819
+ reqIdPrefix: CMD_SUBSCRIBE,
820
+ body: {
821
+ bot_id: client.config.longConnection.botId,
822
+ secret: client.config.longConnection.secret,
823
+ },
824
+ }) || "";
825
+ api?.logger?.info?.(`wecom(bot-longconn): socket opened account=${client.accountId} url=${wsUrl}`);
826
+ });
827
+ bindSocketListener(client.ws, "message", (event) => {
828
+ void handleSocketMessage(client, api, event);
829
+ });
830
+ bindSocketListener(client.ws, "error", (event) => {
831
+ const err = event?.error ?? event;
832
+ api?.logger?.warn?.(
833
+ `wecom(bot-longconn): socket error account=${client.accountId}: ${String(err?.stack || err?.message || err || "unknown error")}`,
834
+ );
835
+ });
836
+ bindSocketListener(client.ws, "close", (eventOrCode, maybeReason) => {
837
+ const event =
838
+ eventOrCode && typeof eventOrCode === "object"
839
+ ? eventOrCode
840
+ : { code: eventOrCode, reason: maybeReason };
841
+ client.connected = false;
842
+ client.socketOpen = false;
843
+ client.subscribeReqId = "";
844
+ client.lastPingReqId = "";
845
+ client.missedHeartbeatAcks = 0;
846
+ clearClientTimers(client);
847
+ clearPendingReplies(client, `socket closed (${normalizeCloseReason(event?.reason) || event?.code || 0})`);
848
+ setWecomConnectionState({
849
+ accountId: client.accountId,
850
+ connected: false,
851
+ transport: "bot.longConnection",
852
+ });
853
+ client.ws = null;
854
+ api?.logger?.warn?.(
855
+ `wecom(bot-longconn): closed account=${client.accountId} code=${Number(event?.code ?? 0)} reason=${normalizeCloseReason(event?.reason)}`,
856
+ );
857
+ scheduleReconnect(client, api);
858
+ });
859
+ bindSocketListener(client.ws, "ping", () => {
860
+ try {
861
+ client.ws?.pong?.();
862
+ } catch {
863
+ // ignore pong failure
864
+ }
865
+ });
866
+ bindSocketListener(client.ws, "pong", () => {
867
+ client.missedHeartbeatAcks = 0;
868
+ });
869
+ }
870
+
871
+ function buildClientSignature(config, proxyUrl) {
872
+ return JSON.stringify({
873
+ accountId: normalizeAccountId(config?.accountId),
874
+ enabled: config?.longConnection?.enabled === true,
875
+ botId: String(config?.longConnection?.botId ?? ""),
876
+ secret: String(config?.longConnection?.secret ?? ""),
877
+ url: normalizeLongConnectionUrl(config?.longConnection?.url ?? DEFAULT_LONG_CONNECTION_URL),
878
+ pingIntervalMs: Number(config?.longConnection?.pingIntervalMs) || 0,
879
+ reconnectDelayMs: Number(config?.longConnection?.reconnectDelayMs) || 0,
880
+ maxReconnectDelayMs: Number(config?.longConnection?.maxReconnectDelayMs) || 0,
881
+ proxyUrl: String(proxyUrl ?? ""),
882
+ });
883
+ }
884
+
885
+ function sync(api) {
886
+ const botConfigs = resolveWecomBotConfigs(api);
887
+ const targetConfigs = (Array.isArray(botConfigs) ? botConfigs : [])
888
+ .filter((item) => item?.enabled === true && item?.longConnection?.enabled === true)
889
+ .map((item) => ({
890
+ ...item,
891
+ accountId: normalizeAccountId(item?.accountId),
892
+ proxyUrl: resolveWecomBotProxyConfig(api, item?.accountId),
893
+ longConnection: {
894
+ ...(item?.longConnection || {}),
895
+ url: normalizeLongConnectionUrl(item?.longConnection?.url),
896
+ },
897
+ }))
898
+ .filter((item) => {
899
+ const botId = String(item?.longConnection?.botId ?? "").trim();
900
+ const secret = String(item?.longConnection?.secret ?? "").trim();
901
+ if (botId && secret) return true;
902
+ api?.logger?.warn?.(
903
+ `wecom(bot-longconn): skipped account=${normalizeAccountId(item?.accountId)} (missing botId/secret)`,
904
+ );
905
+ return false;
906
+ });
907
+
908
+ const wantedAccountIds = new Set(targetConfigs.map((item) => item.accountId));
909
+ for (const [accountId, client] of clients.entries()) {
910
+ if (wantedAccountIds.has(accountId)) continue;
911
+ stopClient(client);
912
+ clients.delete(accountId);
913
+ api?.logger?.info?.(`wecom(bot-longconn): stopped account=${accountId}`);
914
+ }
915
+
916
+ let started = 0;
917
+ for (const config of targetConfigs) {
918
+ const signature = buildClientSignature(config, config.proxyUrl);
919
+ const existing = clients.get(config.accountId);
920
+ if (existing && existing.signature === signature) {
921
+ existing.shouldRun = true;
922
+ if (!existing.connected && !existing.reconnectTimer && !existing.ws) {
923
+ connectClient(existing, api);
924
+ }
925
+ started += 1;
926
+ continue;
927
+ }
928
+ if (existing) {
929
+ stopClient(existing, { reason: "reconfigure" });
930
+ }
931
+ const client = {
932
+ accountId: config.accountId,
933
+ config,
934
+ proxyUrl: config.proxyUrl,
935
+ signature,
936
+ ws: null,
937
+ connected: false,
938
+ shouldRun: true,
939
+ reconnectAttempts: 0,
940
+ pingTimer: null,
941
+ reconnectTimer: null,
942
+ lastConnectStartedAt: 0,
943
+ lastSentAt: 0,
944
+ lastPingReqId: "",
945
+ subscribeReqId: "",
946
+ socketOpen: false,
947
+ missedHeartbeatAcks: 0,
948
+ replyQueues: new Map(),
949
+ pendingAcks: new Map(),
950
+ };
951
+ clients.set(config.accountId, client);
952
+ connectClient(client, api);
953
+ started += 1;
954
+ }
955
+ return { started };
956
+ }
957
+
958
+ function stopAll() {
959
+ for (const client of clients.values()) {
960
+ stopClient(client);
961
+ }
962
+ clients.clear();
963
+ }
964
+
965
+ function getConnectionState(accountId = "default") {
966
+ const client = getClient(accountId);
967
+ return {
968
+ accountId: normalizeAccountId(accountId),
969
+ connected: client?.connected === true,
970
+ longConnectionEnabled: client?.config?.longConnection?.enabled === true,
971
+ };
972
+ }
973
+
974
+ return {
975
+ setProcessBotInboundHandler,
976
+ rememberReplyContext,
977
+ resolveReplyContext,
978
+ pushStreamUpdate,
979
+ sync,
980
+ stopAll,
981
+ getConnectionState,
982
+ };
983
+ }