@yanhaidao/wecom 2.3.3 → 2.3.9

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 (111) hide show
  1. package/.github/workflows/release.yml +69 -1
  2. package/README.md +213 -337
  3. package/assets/03.bot.page.png +0 -0
  4. package/changelog/v2.3.4.md +20 -0
  5. package/changelog/v2.3.9.md +22 -0
  6. package/compat-single-account.md +32 -2
  7. package/index.test.ts +34 -0
  8. package/index.ts +15 -7
  9. package/package.json +8 -7
  10. package/src/agent/api-client.upload.test.ts +1 -2
  11. package/src/agent/handler.ts +82 -9
  12. package/src/agent/index.ts +1 -1
  13. package/src/app/account-runtime.ts +245 -0
  14. package/src/app/bootstrap.ts +29 -0
  15. package/src/app/index.ts +31 -0
  16. package/src/capability/agent/delivery-service.ts +79 -0
  17. package/src/capability/agent/fallback-policy.ts +13 -0
  18. package/src/capability/agent/index.ts +3 -0
  19. package/src/capability/agent/ingress-service.ts +38 -0
  20. package/src/capability/bot/dispatch-config.ts +47 -0
  21. package/src/capability/bot/fallback-delivery.ts +178 -0
  22. package/src/capability/bot/index.ts +1 -0
  23. package/src/capability/bot/local-path-delivery.ts +215 -0
  24. package/src/capability/bot/service.ts +56 -0
  25. package/src/capability/bot/stream-delivery.ts +379 -0
  26. package/src/capability/bot/stream-finalizer.ts +120 -0
  27. package/src/capability/bot/stream-orchestrator.ts +352 -0
  28. package/src/capability/bot/types.ts +8 -0
  29. package/src/capability/index.ts +2 -0
  30. package/src/channel.lifecycle.test.ts +9 -6
  31. package/src/channel.meta.test.ts +12 -0
  32. package/src/channel.ts +48 -21
  33. package/src/config/accounts.ts +223 -283
  34. package/src/config/derived-paths.test.ts +111 -0
  35. package/src/config/derived-paths.ts +41 -0
  36. package/src/config/index.ts +10 -12
  37. package/src/config/runtime-config.ts +46 -0
  38. package/src/config/schema.ts +59 -102
  39. package/src/domain/models.ts +7 -0
  40. package/src/domain/policies.ts +36 -0
  41. package/src/dynamic-agent.ts +6 -0
  42. package/src/gateway-monitor.ts +43 -93
  43. package/src/http.ts +23 -2
  44. package/src/monitor/limits.ts +7 -0
  45. package/src/monitor/state.ts +28 -508
  46. package/src/monitor.active.test.ts +3 -3
  47. package/src/monitor.integration.test.ts +0 -1
  48. package/src/monitor.ts +64 -2603
  49. package/src/monitor.webhook.test.ts +127 -42
  50. package/src/observability/audit-log.ts +48 -0
  51. package/src/observability/legacy-operational-event-store.ts +36 -0
  52. package/src/observability/raw-envelope-log.ts +28 -0
  53. package/src/observability/status-registry.ts +13 -0
  54. package/src/observability/transport-session-view.ts +14 -0
  55. package/src/onboarding.test.ts +219 -0
  56. package/src/onboarding.ts +88 -71
  57. package/src/outbound.test.ts +5 -5
  58. package/src/outbound.ts +18 -66
  59. package/src/runtime/dispatcher.ts +52 -0
  60. package/src/runtime/index.ts +4 -0
  61. package/src/runtime/outbound-intent.ts +4 -0
  62. package/src/runtime/reply-orchestrator.test.ts +38 -0
  63. package/src/runtime/reply-orchestrator.ts +55 -0
  64. package/src/runtime/routing-bridge.ts +19 -0
  65. package/src/runtime/session-manager.ts +76 -0
  66. package/src/runtime.ts +7 -14
  67. package/src/shared/command-auth.ts +1 -17
  68. package/src/shared/media-service.ts +36 -0
  69. package/src/shared/media-types.ts +5 -0
  70. package/src/store/active-reply-store.ts +42 -0
  71. package/src/store/interfaces.ts +11 -0
  72. package/src/store/memory-store.ts +43 -0
  73. package/src/store/stream-batch-store.ts +350 -0
  74. package/src/target.ts +28 -0
  75. package/src/transport/agent-api/client.ts +44 -0
  76. package/src/transport/agent-api/core.ts +367 -0
  77. package/src/transport/agent-api/delivery.ts +41 -0
  78. package/src/transport/agent-api/media-upload.ts +11 -0
  79. package/src/transport/agent-api/reply.ts +39 -0
  80. package/src/transport/agent-callback/http-handler.ts +47 -0
  81. package/src/transport/agent-callback/inbound.ts +5 -0
  82. package/src/transport/agent-callback/reply.ts +13 -0
  83. package/src/transport/agent-callback/request-handler.ts +244 -0
  84. package/src/transport/agent-callback/session.ts +23 -0
  85. package/src/transport/bot-webhook/active-reply.ts +36 -0
  86. package/src/transport/bot-webhook/http-handler.ts +48 -0
  87. package/src/transport/bot-webhook/inbound-normalizer.ts +371 -0
  88. package/src/transport/bot-webhook/inbound.ts +5 -0
  89. package/src/transport/bot-webhook/message-shape.ts +89 -0
  90. package/src/transport/bot-webhook/protocol.ts +148 -0
  91. package/src/transport/bot-webhook/reply.ts +15 -0
  92. package/src/transport/bot-webhook/request-handler.ts +394 -0
  93. package/src/transport/bot-webhook/session.ts +23 -0
  94. package/src/transport/bot-ws/inbound.ts +109 -0
  95. package/src/transport/bot-ws/reply.ts +48 -0
  96. package/src/transport/bot-ws/sdk-adapter.ts +180 -0
  97. package/src/transport/bot-ws/session.ts +28 -0
  98. package/src/transport/http/common.ts +109 -0
  99. package/src/transport/http/registry.ts +92 -0
  100. package/src/transport/http/request-handler.ts +84 -0
  101. package/src/transport/index.ts +14 -0
  102. package/src/types/account.ts +56 -91
  103. package/src/types/config.ts +59 -112
  104. package/src/types/constants.ts +20 -35
  105. package/src/types/events.ts +21 -0
  106. package/src/types/index.ts +14 -38
  107. package/src/types/legacy-stream.ts +50 -0
  108. package/src/types/runtime-context.ts +28 -0
  109. package/src/types/runtime.ts +161 -0
  110. package/src/agent/api-client.ts +0 -383
  111. package/src/monitor/types.ts +0 -136
@@ -0,0 +1,394 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+
3
+ import { getWecomRuntime } from "../../runtime.js";
4
+ import { decryptWecomEncrypted, verifyWecomSignature } from "../../crypto.js";
5
+ import { resolveWecomEgressProxyUrl } from "../../config/index.js";
6
+ import { LIMITS, type StreamStore } from "../../monitor/state.js";
7
+ import type { StartBotAgentStreamParams } from "../../capability/bot/stream-orchestrator.js";
8
+ import type { WecomBotInboundMessage as WecomInboundMessage } from "../../types/index.js";
9
+ import type { WecomRuntimeAuditEvent, WecomWebhookTarget } from "../../types/runtime-context.js";
10
+ import { wecomFetch } from "../../http.js";
11
+ import {
12
+ logRouteFailure,
13
+ resolveQueryParams,
14
+ resolveSignatureParam,
15
+ type RouteFailureReason,
16
+ writeRouteFailure,
17
+ } from "../http/common.js";
18
+ import {
19
+ buildEncryptedBotWebhookReply,
20
+ buildStreamPlaceholderReply,
21
+ buildStreamReplyFromState,
22
+ buildStreamTextPlaceholderReply,
23
+ jsonOk,
24
+ parseWecomPlainMessage,
25
+ readBotWebhookJsonBody,
26
+ resolveBotIdentitySet,
27
+ } from "./protocol.js";
28
+ import { storeActiveReply } from "./active-reply.js";
29
+ import { buildInboundBody, resolveWecomSenderUserId, shouldProcessBotInboundMessage } from "./message-shape.js";
30
+
31
+ const ERROR_HELP = "\n\n遇到问题?联系作者: YanHaidao (微信: YanHaidao)";
32
+
33
+ type RecordBotOperationalEvent = (
34
+ target: Pick<WecomWebhookTarget, "account" | "auditSink">,
35
+ event: Omit<WecomRuntimeAuditEvent, "transport">,
36
+ ) => void;
37
+
38
+ export function createBotWebhookRequestHandler(params: {
39
+ streamStore: StreamStore;
40
+ logInfo: (target: WecomWebhookTarget, message: string) => void;
41
+ logVerbose: (target: WecomWebhookTarget, message: string) => void;
42
+ recordBotOperationalEvent: RecordBotOperationalEvent;
43
+ startAgentForStream: (params: StartBotAgentStreamParams) => Promise<void>;
44
+ }) {
45
+ const { streamStore, logInfo, logVerbose, recordBotOperationalEvent, startAgentForStream } = params;
46
+
47
+ return async function handleBotWebhookRequest(args: {
48
+ req: IncomingMessage;
49
+ res: ServerResponse;
50
+ path: string;
51
+ reqId: string;
52
+ targets: WecomWebhookTarget[];
53
+ }): Promise<boolean> {
54
+ const { req, res, path, reqId, targets } = args;
55
+ const query = resolveQueryParams(req);
56
+ const timestamp = query.get("timestamp") ?? "";
57
+ const nonce = query.get("nonce") ?? "";
58
+ const signature = resolveSignatureParam(query);
59
+
60
+ if (req.method === "GET") {
61
+ const echostr = query.get("echostr") ?? "";
62
+ const signatureMatches = targets.filter(
63
+ (target) =>
64
+ target.account.token &&
65
+ verifyWecomSignature({ token: target.account.token, timestamp, nonce, encrypt: echostr, signature }),
66
+ );
67
+ if (signatureMatches.length !== 1) {
68
+ const reason: RouteFailureReason =
69
+ signatureMatches.length === 0 ? "wecom_account_not_found" : "wecom_account_conflict";
70
+ const candidateIds = (signatureMatches.length > 0 ? signatureMatches : targets).map((target) => target.account.accountId);
71
+ logRouteFailure({
72
+ reqId,
73
+ path,
74
+ method: "GET",
75
+ reason,
76
+ candidateAccountIds: candidateIds,
77
+ });
78
+ writeRouteFailure(
79
+ res,
80
+ reason,
81
+ reason === "wecom_account_conflict"
82
+ ? "Bot callback account conflict: multiple accounts matched signature."
83
+ : "Bot callback account not found: signature verification failed.",
84
+ );
85
+ return true;
86
+ }
87
+ const target = signatureMatches[0]!;
88
+ try {
89
+ const plain = decryptWecomEncrypted({
90
+ encodingAESKey: target.account.encodingAESKey,
91
+ receiveId: target.account.receiveId,
92
+ encrypt: echostr,
93
+ });
94
+ res.statusCode = 200;
95
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
96
+ res.end(plain);
97
+ return true;
98
+ } catch (err) {
99
+ res.statusCode = 400;
100
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
101
+ res.end(`decrypt failed - 解密失败,请检查 EncodingAESKey${ERROR_HELP}`);
102
+ return true;
103
+ }
104
+ }
105
+
106
+ if (req.method !== "POST") return false;
107
+
108
+ const body = await readBotWebhookJsonBody(req, 1024 * 1024);
109
+ if (!body.ok) {
110
+ res.statusCode = 400;
111
+ res.end(body.error || "invalid payload");
112
+ return true;
113
+ }
114
+ const record = body.value as any;
115
+ const encrypt = String(record?.encrypt ?? record?.Encrypt ?? "");
116
+ console.log(
117
+ `[wecom] inbound(bot): reqId=${reqId} rawJsonBytes=${Buffer.byteLength(JSON.stringify(record), "utf8")} hasEncrypt=${Boolean(encrypt)} encryptLen=${encrypt.length}`,
118
+ );
119
+ const signatureMatches = targets.filter(
120
+ (target) =>
121
+ target.account.token && verifyWecomSignature({ token: target.account.token, timestamp, nonce, encrypt, signature }),
122
+ );
123
+ if (signatureMatches.length !== 1) {
124
+ const reason: RouteFailureReason =
125
+ signatureMatches.length === 0 ? "wecom_account_not_found" : "wecom_account_conflict";
126
+ const candidateIds = (signatureMatches.length > 0 ? signatureMatches : targets).map((target) => target.account.accountId);
127
+ logRouteFailure({
128
+ reqId,
129
+ path,
130
+ method: "POST",
131
+ reason,
132
+ candidateAccountIds: candidateIds,
133
+ });
134
+ writeRouteFailure(
135
+ res,
136
+ reason,
137
+ reason === "wecom_account_conflict"
138
+ ? "Bot callback account conflict: multiple accounts matched signature."
139
+ : "Bot callback account not found: signature verification failed.",
140
+ );
141
+ return true;
142
+ }
143
+
144
+ const target = signatureMatches[0]!;
145
+ let msg: WecomInboundMessage;
146
+ try {
147
+ const plain = decryptWecomEncrypted({
148
+ encodingAESKey: target.account.encodingAESKey,
149
+ receiveId: target.account.receiveId,
150
+ encrypt,
151
+ });
152
+ msg = parseWecomPlainMessage(plain);
153
+ } catch {
154
+ res.statusCode = 400;
155
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
156
+ res.end(`decrypt failed - 解密失败,请检查 EncodingAESKey${ERROR_HELP}`);
157
+ return true;
158
+ }
159
+
160
+ const expected = resolveBotIdentitySet(target);
161
+ if (expected.size > 0) {
162
+ const inboundAibotId = String((msg as any).aibotid ?? "").trim();
163
+ if (!inboundAibotId || !expected.has(inboundAibotId)) {
164
+ target.runtime.error?.(
165
+ `[wecom] inbound(bot): reqId=${reqId} accountId=${target.account.accountId} aibotid_mismatch expected=${Array.from(expected).join(",")} actual=${inboundAibotId || "N/A"}`,
166
+ );
167
+ }
168
+ }
169
+
170
+ logInfo(target, `inbound(bot): reqId=${reqId} selectedAccount=${target.account.accountId} path=${path}`);
171
+ target.touchTransportSession?.({ lastInboundAt: Date.now(), running: true });
172
+ const msgtype = String(msg.msgtype ?? "").toLowerCase();
173
+ const proxyUrl = resolveWecomEgressProxyUrl(target.config);
174
+
175
+ if (msgtype === "event") {
176
+ const eventtype = String((msg as any).event?.eventtype ?? "").toLowerCase();
177
+
178
+ if (eventtype === "template_card_event") {
179
+ const msgid = msg.msgid ? String(msg.msgid) : undefined;
180
+ if (msgid && streamStore.getStreamByMsgId(msgid)) {
181
+ logVerbose(target, `template_card_event: already processed msgid=${msgid}, skipping`);
182
+ recordBotOperationalEvent(target, {
183
+ category: "duplicate-reply",
184
+ messageId: msgid,
185
+ summary: `duplicate template card event msgid=${msgid}`,
186
+ raw: {
187
+ transport: "bot-webhook",
188
+ envelopeType: "json",
189
+ body: msg,
190
+ },
191
+ });
192
+ jsonOk(res, buildEncryptedBotWebhookReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
193
+ return true;
194
+ }
195
+
196
+ const cardEvent = (msg as any).event?.template_card_event;
197
+ let interactionDesc = `[卡片交互] 按钮: ${cardEvent?.event_key || "unknown"}`;
198
+ if (cardEvent?.selected_items?.selected_item?.length) {
199
+ const selects = cardEvent.selected_items.selected_item.map(
200
+ (i: any) => `${i.question_key}=${i.option_ids?.option_id?.join(",")}`,
201
+ );
202
+ interactionDesc += ` 选择: ${selects.join("; ")}`;
203
+ }
204
+ if (cardEvent?.task_id) interactionDesc += ` (任务ID: ${cardEvent.task_id})`;
205
+
206
+ jsonOk(res, buildEncryptedBotWebhookReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
207
+
208
+ const streamId = streamStore.createStream({ msgid });
209
+ streamStore.markStarted(streamId);
210
+ storeActiveReply(streamId, msg.response_url);
211
+ const core = getWecomRuntime();
212
+ startAgentForStream({
213
+ target: { ...target, core },
214
+ accountId: target.account.accountId,
215
+ msg: { ...msg, msgtype: "text", text: { content: interactionDesc } } as any,
216
+ streamId,
217
+ }).catch((err) => target.runtime.error?.(`interaction failed: ${String(err)}`));
218
+ return true;
219
+ }
220
+
221
+ if (eventtype === "enter_chat") {
222
+ const welcome = target.account.config.welcomeText?.trim();
223
+ jsonOk(
224
+ res,
225
+ buildEncryptedBotWebhookReply({
226
+ account: target.account,
227
+ plaintextJson: welcome ? { msgtype: "text", text: { content: welcome } } : {},
228
+ nonce,
229
+ timestamp,
230
+ }),
231
+ );
232
+ return true;
233
+ }
234
+
235
+ jsonOk(res, buildEncryptedBotWebhookReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
236
+ return true;
237
+ }
238
+
239
+ if (msgtype === "stream") {
240
+ const streamId = String((msg as any).stream?.id ?? "").trim();
241
+ const state = streamStore.getStream(streamId);
242
+ const reply = state
243
+ ? buildStreamReplyFromState(state)
244
+ : buildStreamReplyFromState({
245
+ streamId: streamId || "unknown",
246
+ createdAt: Date.now(),
247
+ updatedAt: Date.now(),
248
+ started: true,
249
+ finished: true,
250
+ content: "",
251
+ });
252
+ jsonOk(res, buildEncryptedBotWebhookReply({ account: target.account, plaintextJson: reply, nonce, timestamp }));
253
+ return true;
254
+ }
255
+
256
+ try {
257
+ const decision = shouldProcessBotInboundMessage(msg);
258
+ if (!decision.shouldProcess) {
259
+ logInfo(
260
+ target,
261
+ `inbound: skipped msgtype=${msgtype} reason=${decision.reason} chattype=${String(msg.chattype ?? "")} chatid=${String(msg.chatid ?? "")} from=${resolveWecomSenderUserId(msg) || "N/A"}`,
262
+ );
263
+ jsonOk(res, buildEncryptedBotWebhookReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
264
+ return true;
265
+ }
266
+
267
+ const userid = decision.senderUserId!;
268
+ const chatId = decision.chatId ?? userid;
269
+ const conversationKey = `wecom:${target.account.accountId}:${userid}:${chatId}`;
270
+ const msgContent = buildInboundBody(msg);
271
+
272
+ logInfo(
273
+ target,
274
+ `inbound: msgtype=${msgtype} chattype=${String(msg.chattype ?? "")} chatid=${String(msg.chatid ?? "")} from=${userid} msgid=${String(msg.msgid ?? "")} hasResponseUrl=${Boolean((msg as any).response_url)}`,
275
+ );
276
+
277
+ if (msg.msgid) {
278
+ const existingStreamId = streamStore.getStreamByMsgId(String(msg.msgid));
279
+ if (existingStreamId) {
280
+ logInfo(target, `message: 重复的 msgid=${msg.msgid},跳过处理并返回占位符 streamId=${existingStreamId}`);
281
+ recordBotOperationalEvent(target, {
282
+ category: "duplicate-reply",
283
+ messageId: String(msg.msgid),
284
+ summary: `duplicate inbound msgid=${String(msg.msgid)} streamId=${existingStreamId}`,
285
+ raw: {
286
+ transport: "bot-webhook",
287
+ envelopeType: "json",
288
+ body: msg,
289
+ },
290
+ });
291
+ jsonOk(
292
+ res,
293
+ buildEncryptedBotWebhookReply({
294
+ account: target.account,
295
+ plaintextJson: buildStreamPlaceholderReply({
296
+ streamId: existingStreamId,
297
+ placeholderContent: target.account.config.streamPlaceholderContent,
298
+ }),
299
+ nonce,
300
+ timestamp,
301
+ }),
302
+ );
303
+ return true;
304
+ }
305
+ }
306
+
307
+ const { streamId, status } = streamStore.addPendingMessage({
308
+ conversationKey,
309
+ target,
310
+ msg,
311
+ msgContent,
312
+ nonce,
313
+ timestamp,
314
+ debounceMs: (target.account.config as any).debounceMs,
315
+ });
316
+
317
+ if (msg.response_url) {
318
+ storeActiveReply(streamId, msg.response_url, proxyUrl);
319
+ }
320
+
321
+ const defaultPlaceholder = target.account.config.streamPlaceholderContent;
322
+ const queuedPlaceholder = "已收到,已排队处理中...";
323
+ const mergedQueuedPlaceholder = "已收到,已合并排队处理中...";
324
+
325
+ if (status === "active_new") {
326
+ jsonOk(
327
+ res,
328
+ buildEncryptedBotWebhookReply({
329
+ account: target.account,
330
+ plaintextJson: buildStreamPlaceholderReply({
331
+ streamId,
332
+ placeholderContent: defaultPlaceholder,
333
+ }),
334
+ nonce,
335
+ timestamp,
336
+ }),
337
+ );
338
+ return true;
339
+ }
340
+
341
+ if (status === "queued_new") {
342
+ logInfo(target, `queue: 已进入下一批次 streamId=${streamId} msgid=${String(msg.msgid ?? "")}`);
343
+ jsonOk(
344
+ res,
345
+ buildEncryptedBotWebhookReply({
346
+ account: target.account,
347
+ plaintextJson: buildStreamPlaceholderReply({
348
+ streamId,
349
+ placeholderContent: queuedPlaceholder,
350
+ }),
351
+ nonce,
352
+ timestamp,
353
+ }),
354
+ );
355
+ return true;
356
+ }
357
+
358
+ const ackStreamId = streamStore.createStream({ msgid: String(msg.msgid ?? "") || undefined });
359
+ streamStore.updateStream(ackStreamId, (s) => {
360
+ s.finished = false;
361
+ s.started = true;
362
+ s.content = mergedQueuedPlaceholder;
363
+ });
364
+ if (msg.msgid) streamStore.setStreamIdForMsgId(String(msg.msgid), ackStreamId);
365
+ streamStore.addAckStreamForBatch({ batchStreamId: streamId, ackStreamId });
366
+ logInfo(
367
+ target,
368
+ `queue: 已合并排队(回执流) ackStreamId=${ackStreamId} mergedIntoStreamId=${streamId} msgid=${String(msg.msgid ?? "")}`,
369
+ );
370
+ jsonOk(
371
+ res,
372
+ buildEncryptedBotWebhookReply({
373
+ account: target.account,
374
+ plaintextJson: buildStreamTextPlaceholderReply({ streamId: ackStreamId, content: mergedQueuedPlaceholder }),
375
+ nonce,
376
+ timestamp,
377
+ }),
378
+ );
379
+ return true;
380
+ } catch (err) {
381
+ target.runtime.error?.(`[wecom] Bot message handler crashed: ${String(err)}`);
382
+ jsonOk(
383
+ res,
384
+ buildEncryptedBotWebhookReply({
385
+ account: target.account,
386
+ plaintextJson: { msgtype: "text", text: { content: "服务内部错误:Bot 处理异常,请稍后重试。" } },
387
+ nonce,
388
+ timestamp,
389
+ }),
390
+ );
391
+ return true;
392
+ }
393
+ };
394
+ }
@@ -0,0 +1,23 @@
1
+ import type { TransportSessionSnapshot } from "../../types/index.js";
2
+
3
+ export function createBotWebhookSessionSnapshot(params: {
4
+ accountId: string;
5
+ running: boolean;
6
+ lastInboundAt?: number;
7
+ lastOutboundAt?: number;
8
+ lastError?: string;
9
+ }): TransportSessionSnapshot {
10
+ return {
11
+ accountId: params.accountId,
12
+ transport: "bot-webhook",
13
+ running: params.running,
14
+ ownerId: `${params.accountId}:bot-webhook`,
15
+ connected: params.running,
16
+ authenticated: true,
17
+ lastConnectedAt: params.running ? Date.now() : undefined,
18
+ lastDisconnectedAt: params.running ? undefined : Date.now(),
19
+ lastInboundAt: params.lastInboundAt,
20
+ lastOutboundAt: params.lastOutboundAt,
21
+ lastError: params.lastError,
22
+ };
23
+ }
@@ -0,0 +1,109 @@
1
+ import type { BaseMessage, EventMessage, WsFrame } from "@wecom/aibot-node-sdk";
2
+
3
+ import type { ResolvedBotAccount, UnifiedInboundEvent, WecomInboundKind } from "../../types/index.js";
4
+
5
+ function resolveInboundKind(message: BaseMessage | EventMessage): WecomInboundKind {
6
+ if (message.msgtype === "event") {
7
+ const eventType = String((message as EventMessage).event?.eventtype ?? "").trim();
8
+ if (eventType === "enter_chat") return "welcome";
9
+ if (eventType === "template_card_event") return "template-card-event";
10
+ return "event";
11
+ }
12
+ switch (message.msgtype) {
13
+ case "image":
14
+ return "image";
15
+ case "file":
16
+ return "file";
17
+ case "voice":
18
+ return "voice";
19
+ case "mixed":
20
+ return "mixed";
21
+ default:
22
+ return "text";
23
+ }
24
+ }
25
+
26
+ function resolveEventText(message: BaseMessage | EventMessage, account: ResolvedBotAccount): string {
27
+ if (message.msgtype === "text") {
28
+ return message.text?.content ?? "";
29
+ }
30
+ if (message.msgtype === "voice") {
31
+ return message.voice?.content ?? "";
32
+ }
33
+ if (message.msgtype === "mixed") {
34
+ return (message.mixed?.msg_item ?? [])
35
+ .map((item: { msgtype: string; text?: { content?: string } }) =>
36
+ item.msgtype === "text" ? item.text?.content ?? "" : "[image]",
37
+ )
38
+ .filter(Boolean)
39
+ .join("\n");
40
+ }
41
+ if (message.msgtype === "image") {
42
+ return "[image]";
43
+ }
44
+ if (message.msgtype === "file") {
45
+ return "[file]";
46
+ }
47
+ const event = message as EventMessage;
48
+ if (event.event?.eventtype === "enter_chat" && account.config.welcomeText) {
49
+ return account.config.welcomeText;
50
+ }
51
+ return `[event:${String(event.event?.eventtype ?? "unknown")}]`;
52
+ }
53
+
54
+ export function mapBotWsFrameToInboundEvent(params: {
55
+ account: ResolvedBotAccount;
56
+ frame: WsFrame<BaseMessage | EventMessage>;
57
+ }): UnifiedInboundEvent {
58
+ const { account, frame } = params;
59
+ const body = frame.body;
60
+ if (!body) {
61
+ throw new Error("Bot WS frame body is required");
62
+ }
63
+ const peerKind = body.chattype === "group" ? "group" : "direct";
64
+ const senderId = body.from?.userid ?? "unknown";
65
+ const peerId = peerKind === "group" ? body.chatid ?? senderId : senderId;
66
+ const inboundKind = resolveInboundKind(body);
67
+
68
+ return {
69
+ accountId: account.accountId,
70
+ capability: "bot",
71
+ transport: "bot-ws",
72
+ inboundKind,
73
+ messageId: body.msgid,
74
+ conversation: {
75
+ accountId: account.accountId,
76
+ peerKind,
77
+ peerId,
78
+ senderId,
79
+ },
80
+ senderName: senderId,
81
+ text: resolveEventText(body, account),
82
+ timestamp: typeof body.create_time === "number" ? body.create_time : Date.now(),
83
+ raw: {
84
+ transport: "bot-ws",
85
+ command: frame.cmd,
86
+ headers: frame.headers,
87
+ body,
88
+ envelopeType: "ws",
89
+ },
90
+ replyContext: {
91
+ transport: "bot-ws",
92
+ accountId: account.accountId,
93
+ reqId: frame.headers.req_id,
94
+ passiveWindowMs: 5_000,
95
+ raw: {
96
+ transport: "bot-ws",
97
+ command: frame.cmd,
98
+ headers: frame.headers,
99
+ body,
100
+ envelopeType: "ws",
101
+ },
102
+ },
103
+ attachments: body.msgtype === "image"
104
+ ? [{ name: "image", remoteUrl: body.image?.url, aesKey: body.image?.aeskey }]
105
+ : body.msgtype === "file"
106
+ ? [{ name: "file", remoteUrl: body.file?.url, aesKey: body.file?.aeskey }]
107
+ : undefined,
108
+ };
109
+ }
@@ -0,0 +1,48 @@
1
+ import { generateReqId, type WsFrame, type BaseMessage, type EventMessage, type WSClient } from "@wecom/aibot-node-sdk";
2
+
3
+ import type { ReplyHandle, ReplyPayload } from "../../types/index.js";
4
+
5
+ export function createBotWsReplyHandle(params: {
6
+ client: WSClient;
7
+ frame: WsFrame<BaseMessage | EventMessage>;
8
+ accountId: string;
9
+ onDeliver?: () => void;
10
+ onFail?: (error: unknown) => void;
11
+ }): ReplyHandle {
12
+ let streamId: string | undefined;
13
+ const resolveStreamId = () => {
14
+ streamId ||= generateReqId("stream");
15
+ return streamId;
16
+ };
17
+
18
+ return {
19
+ context: {
20
+ transport: "bot-ws",
21
+ accountId: params.accountId,
22
+ reqId: params.frame.headers.req_id,
23
+ raw: {
24
+ transport: "bot-ws",
25
+ command: params.frame.cmd,
26
+ headers: params.frame.headers,
27
+ body: params.frame.body,
28
+ envelopeType: "ws",
29
+ },
30
+ },
31
+ deliver: async (payload: ReplyPayload, info) => {
32
+ if (payload.isReasoning) {
33
+ return;
34
+ }
35
+ const text = payload.text?.trim();
36
+ if (!text) {
37
+ return;
38
+ }
39
+ await params.client.replyStream(params.frame, resolveStreamId(), text, info.kind === "final");
40
+ params.onDeliver?.();
41
+ },
42
+ fail: async (error: unknown) => {
43
+ const message = error instanceof Error ? error.message : String(error);
44
+ await params.client.replyStream(params.frame, resolveStreamId(), `WeCom WS reply failed: ${message}`, true);
45
+ params.onFail?.(error);
46
+ },
47
+ };
48
+ }