@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,371 @@
1
+ import { decryptWecomMediaWithMeta } from "../../media.js";
2
+ import { resolveWecomEgressProxyUrl, resolveWecomMediaMaxBytes } from "../../config/index.js";
3
+ import type { WecomBotInboundMessage as WecomInboundMessage } from "../../types/index.js";
4
+ import type { WecomWebhookTarget } from "../../types/runtime-context.js";
5
+ import { buildInboundBody } from "./message-shape.js";
6
+
7
+ const MIME_BY_EXT: Record<string, string> = {
8
+ txt: "text/plain",
9
+ md: "text/markdown",
10
+ json: "application/json",
11
+ csv: "text/csv",
12
+ html: "text/html",
13
+ pdf: "application/pdf",
14
+ doc: "application/msword",
15
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
16
+ xls: "application/vnd.ms-excel",
17
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
18
+ ppt: "application/vnd.ms-powerpoint",
19
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
20
+ jpg: "image/jpeg",
21
+ jpeg: "image/jpeg",
22
+ png: "image/png",
23
+ gif: "image/gif",
24
+ webp: "image/webp",
25
+ bmp: "image/bmp",
26
+ ogg: "audio/ogg",
27
+ wav: "audio/wav",
28
+ mp3: "audio/mpeg",
29
+ mp4: "video/mp4",
30
+ zip: "application/zip",
31
+ bin: "application/octet-stream",
32
+ };
33
+
34
+ const EXT_BY_MIME: Record<string, string> = {
35
+ ...Object.fromEntries(Object.entries(MIME_BY_EXT).map(([ext, mime]) => [mime, ext])),
36
+ "application/octet-stream": "bin",
37
+ };
38
+
39
+ const GENERIC_CONTENT_TYPES = new Set([
40
+ "application/octet-stream",
41
+ "binary/octet-stream",
42
+ "application/download",
43
+ ]);
44
+
45
+ export type BotInboundMedia = {
46
+ buffer: Buffer;
47
+ contentType: string;
48
+ filename: string;
49
+ };
50
+
51
+ export type BotInboundNormalizationResult = {
52
+ body: string;
53
+ media?: BotInboundMedia;
54
+ };
55
+
56
+ function normalizeContentType(raw?: string | null): string | undefined {
57
+ const normalized = String(raw ?? "").trim().split(";")[0]?.trim().toLowerCase();
58
+ return normalized || undefined;
59
+ }
60
+
61
+ function isGenericContentType(raw?: string | null): boolean {
62
+ const normalized = normalizeContentType(raw);
63
+ if (!normalized) return true;
64
+ return GENERIC_CONTENT_TYPES.has(normalized);
65
+ }
66
+
67
+ export function guessContentTypeFromPath(filePath: string): string | undefined {
68
+ const ext = filePath.split(".").pop()?.toLowerCase();
69
+ if (!ext) return undefined;
70
+ return MIME_BY_EXT[ext];
71
+ }
72
+
73
+ function guessExtensionFromContentType(contentType?: string): string | undefined {
74
+ const normalized = normalizeContentType(contentType);
75
+ if (!normalized) return undefined;
76
+ if (normalized === "image/jpeg") return "jpg";
77
+ return EXT_BY_MIME[normalized];
78
+ }
79
+
80
+ function extractFileNameFromUrl(rawUrl?: string): string | undefined {
81
+ const s = String(rawUrl ?? "").trim();
82
+ if (!s) return undefined;
83
+ try {
84
+ const u = new URL(s);
85
+ const name = decodeURIComponent(u.pathname.split("/").pop() ?? "").trim();
86
+ return name || undefined;
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ function sanitizeInboundFilename(raw?: string): string | undefined {
93
+ const s = String(raw ?? "").trim();
94
+ if (!s) return undefined;
95
+ const base = s.split(/[\\/]/).pop()?.trim() ?? "";
96
+ if (!base) return undefined;
97
+ const sanitized = base.replace(/[\u0000-\u001f<>:"|?*]/g, "_").trim();
98
+ return sanitized || undefined;
99
+ }
100
+
101
+ function hasLikelyExtension(name?: string): boolean {
102
+ if (!name) return false;
103
+ return /\.[a-z0-9]{1,16}$/i.test(name);
104
+ }
105
+
106
+ function detectMimeFromBuffer(buffer: Buffer): string | undefined {
107
+ if (!buffer || buffer.length < 4) return undefined;
108
+ if (
109
+ buffer.length >= 8 &&
110
+ buffer[0] === 0x89 &&
111
+ buffer[1] === 0x50 &&
112
+ buffer[2] === 0x4e &&
113
+ buffer[3] === 0x47 &&
114
+ buffer[4] === 0x0d &&
115
+ buffer[5] === 0x0a &&
116
+ buffer[6] === 0x1a &&
117
+ buffer[7] === 0x0a
118
+ ) {
119
+ return "image/png";
120
+ }
121
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
122
+ return "image/jpeg";
123
+ }
124
+ if (buffer.subarray(0, 6).toString("ascii") === "GIF87a" || buffer.subarray(0, 6).toString("ascii") === "GIF89a") {
125
+ return "image/gif";
126
+ }
127
+ if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") {
128
+ return "image/webp";
129
+ }
130
+ if (buffer[0] === 0x42 && buffer[1] === 0x4d) {
131
+ return "image/bmp";
132
+ }
133
+ if (buffer.subarray(0, 5).toString("ascii") === "%PDF-") {
134
+ return "application/pdf";
135
+ }
136
+ if (buffer.subarray(0, 4).toString("ascii") === "OggS") {
137
+ return "audio/ogg";
138
+ }
139
+ if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WAVE") {
140
+ return "audio/wav";
141
+ }
142
+ if (buffer.subarray(0, 3).toString("ascii") === "ID3" || (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0)) {
143
+ return "audio/mpeg";
144
+ }
145
+ if (buffer.length >= 12 && buffer.subarray(4, 8).toString("ascii") === "ftyp") {
146
+ return "video/mp4";
147
+ }
148
+ if (
149
+ buffer.length >= 8 &&
150
+ buffer[0] === 0xd0 &&
151
+ buffer[1] === 0xcf &&
152
+ buffer[2] === 0x11 &&
153
+ buffer[3] === 0xe0 &&
154
+ buffer[4] === 0xa1 &&
155
+ buffer[5] === 0xb1 &&
156
+ buffer[6] === 0x1a &&
157
+ buffer[7] === 0xe1
158
+ ) {
159
+ return "application/msword";
160
+ }
161
+ const zipMagic =
162
+ (buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x03 && buffer[3] === 0x04) ||
163
+ (buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x05 && buffer[3] === 0x06) ||
164
+ (buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x07 && buffer[3] === 0x08);
165
+ if (zipMagic) {
166
+ const probe = buffer.subarray(0, Math.min(buffer.length, 512 * 1024));
167
+ if (probe.includes(Buffer.from("word/"))) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
168
+ if (probe.includes(Buffer.from("xl/"))) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
169
+ if (probe.includes(Buffer.from("ppt/"))) return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
170
+ return "application/zip";
171
+ }
172
+ const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
173
+ let printable = 0;
174
+ for (const b of sample) {
175
+ if (b === 0x00) return undefined;
176
+ if (b === 0x09 || b === 0x0a || b === 0x0d || (b >= 0x20 && b <= 0x7e)) {
177
+ printable += 1;
178
+ }
179
+ }
180
+ if (sample.length > 0 && printable / sample.length > 0.95) {
181
+ return "text/plain";
182
+ }
183
+ return undefined;
184
+ }
185
+
186
+ function resolveInlineFileName(input: unknown): string | undefined {
187
+ return sanitizeInboundFilename(String(input ?? "").trim());
188
+ }
189
+
190
+ function pickBotFileName(msg: WecomInboundMessage, item?: Record<string, any>): string | undefined {
191
+ const fromItem = item
192
+ ? resolveInlineFileName(item?.filename ?? item?.file_name ?? item?.fileName ?? item?.name ?? item?.title)
193
+ : undefined;
194
+ if (fromItem) return fromItem;
195
+ return resolveInlineFileName(
196
+ (msg as any)?.file?.filename ??
197
+ (msg as any)?.file?.file_name ??
198
+ (msg as any)?.file?.fileName ??
199
+ (msg as any)?.file?.name ??
200
+ (msg as any)?.file?.title ??
201
+ (msg as any)?.filename ??
202
+ (msg as any)?.fileName ??
203
+ (msg as any)?.FileName,
204
+ );
205
+ }
206
+
207
+ function inferInboundMediaMeta(params: {
208
+ kind: "image" | "file";
209
+ buffer: Buffer;
210
+ sourceUrl?: string;
211
+ sourceContentType?: string;
212
+ sourceFilename?: string;
213
+ explicitFilename?: string;
214
+ }): { contentType: string; filename: string } {
215
+ const headerType = normalizeContentType(params.sourceContentType);
216
+ const magicType = detectMimeFromBuffer(params.buffer);
217
+ const rawUrlName = sanitizeInboundFilename(extractFileNameFromUrl(params.sourceUrl));
218
+ const guessedByUrl = hasLikelyExtension(rawUrlName) ? rawUrlName : undefined;
219
+ const explicitName = sanitizeInboundFilename(params.explicitFilename);
220
+ const sourceName = sanitizeInboundFilename(params.sourceFilename);
221
+ const chosenName = explicitName || sourceName || guessedByUrl;
222
+ const typeByName = chosenName ? guessContentTypeFromPath(chosenName) : undefined;
223
+
224
+ let contentType: string;
225
+ if (params.kind === "image") {
226
+ if (magicType?.startsWith("image/")) contentType = magicType;
227
+ else if (headerType?.startsWith("image/")) contentType = headerType;
228
+ else if (typeByName?.startsWith("image/")) contentType = typeByName;
229
+ else contentType = "image/jpeg";
230
+ } else {
231
+ contentType = magicType || (!isGenericContentType(headerType) ? headerType! : undefined) || typeByName || "application/octet-stream";
232
+ }
233
+
234
+ const hasExt = Boolean(chosenName && /\.[a-z0-9]{1,16}$/i.test(chosenName));
235
+ const ext = guessExtensionFromContentType(contentType) || (params.kind === "image" ? "jpg" : "bin");
236
+ const filename = chosenName ? (hasExt ? chosenName : `${chosenName}.${ext}`) : `${params.kind}.${ext}`;
237
+ return { contentType, filename };
238
+ }
239
+
240
+ export function looksLikeSendLocalFileIntent(rawBody: string): boolean {
241
+ const t = rawBody.trim();
242
+ if (!t) return false;
243
+ return /(发送|发给|发到|转发|把.*发|把.*发送|帮我发|给我发)/.test(t);
244
+ }
245
+
246
+ export async function processBotInboundMessage(params: {
247
+ target: WecomWebhookTarget;
248
+ msg: WecomInboundMessage;
249
+ recordOperationalIssue: (event: {
250
+ category: "media-decrypt-failed";
251
+ messageId?: string;
252
+ summary: string;
253
+ raw: { transport: "bot-webhook"; envelopeType: "json"; body: WecomInboundMessage };
254
+ error?: string;
255
+ }) => void;
256
+ }): Promise<BotInboundNormalizationResult> {
257
+ const { target, msg, recordOperationalIssue } = params;
258
+ const msgtype = String(msg.msgtype ?? "").toLowerCase();
259
+ const aesKey = target.account.encodingAESKey;
260
+ const maxBytes = resolveWecomMediaMaxBytes(target.config);
261
+ const proxyUrl = resolveWecomEgressProxyUrl(target.config);
262
+
263
+ if (msgtype === "image") {
264
+ const url = String((msg as any).image?.url ?? "").trim();
265
+ if (url && aesKey) {
266
+ try {
267
+ const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
268
+ const inferred = inferInboundMediaMeta({
269
+ kind: "image",
270
+ buffer: decrypted.buffer,
271
+ sourceUrl: decrypted.sourceUrl || url,
272
+ sourceContentType: decrypted.sourceContentType,
273
+ sourceFilename: decrypted.sourceFilename,
274
+ explicitFilename: pickBotFileName(msg),
275
+ });
276
+ return { body: "[image]", media: { buffer: decrypted.buffer, contentType: inferred.contentType, filename: inferred.filename } };
277
+ } catch (err) {
278
+ target.runtime.error?.(`图片解密失败: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`);
279
+ recordOperationalIssue({
280
+ category: "media-decrypt-failed",
281
+ messageId: msg.msgid ? String(msg.msgid) : undefined,
282
+ summary: `image decrypt failed url=${url}`,
283
+ raw: { transport: "bot-webhook", envelopeType: "json", body: msg },
284
+ error: err instanceof Error ? err.message : String(err),
285
+ });
286
+ const errorMessage = typeof err === "object" && err ? `${(err as any).message}${(err as any).cause ? ` (cause: ${String((err as any).cause)})` : ""}` : String(err);
287
+ return { body: `[image] (decryption failed: ${errorMessage})` };
288
+ }
289
+ }
290
+ }
291
+
292
+ if (msgtype === "file") {
293
+ const url = String((msg as any).file?.url ?? "").trim();
294
+ if (url && aesKey) {
295
+ try {
296
+ const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
297
+ const inferred = inferInboundMediaMeta({
298
+ kind: "file",
299
+ buffer: decrypted.buffer,
300
+ sourceUrl: decrypted.sourceUrl || url,
301
+ sourceContentType: decrypted.sourceContentType,
302
+ sourceFilename: decrypted.sourceFilename,
303
+ explicitFilename: pickBotFileName(msg),
304
+ });
305
+ return { body: "[file]", media: { buffer: decrypted.buffer, contentType: inferred.contentType, filename: inferred.filename } };
306
+ } catch (err) {
307
+ target.runtime.error?.(`Failed to decrypt inbound file: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`);
308
+ recordOperationalIssue({
309
+ category: "media-decrypt-failed",
310
+ messageId: msg.msgid ? String(msg.msgid) : undefined,
311
+ summary: `file decrypt failed url=${url}`,
312
+ raw: { transport: "bot-webhook", envelopeType: "json", body: msg },
313
+ error: err instanceof Error ? err.message : String(err),
314
+ });
315
+ const errorMessage = typeof err === "object" && err ? `${(err as any).message}${(err as any).cause ? ` (cause: ${String((err as any).cause)})` : ""}` : String(err);
316
+ return { body: `[file] (decryption failed: ${errorMessage})` };
317
+ }
318
+ }
319
+ }
320
+
321
+ if (msgtype === "mixed") {
322
+ const items = (msg as any).mixed?.msg_item;
323
+ if (Array.isArray(items)) {
324
+ let foundMedia: BotInboundNormalizationResult["media"];
325
+ const bodyParts: string[] = [];
326
+ for (const item of items) {
327
+ const t = String(item.msgtype ?? "").toLowerCase();
328
+ if (t === "text") {
329
+ const content = String(item.text?.content ?? "").trim();
330
+ if (content) bodyParts.push(content);
331
+ continue;
332
+ }
333
+ if ((t === "image" || t === "file") && !foundMedia && aesKey) {
334
+ const url = String(item[t]?.url ?? "").trim();
335
+ if (url) {
336
+ try {
337
+ const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
338
+ const inferred = inferInboundMediaMeta({
339
+ kind: t,
340
+ buffer: decrypted.buffer,
341
+ sourceUrl: decrypted.sourceUrl || url,
342
+ sourceContentType: decrypted.sourceContentType,
343
+ sourceFilename: decrypted.sourceFilename,
344
+ explicitFilename: pickBotFileName(msg, item?.[t]),
345
+ });
346
+ foundMedia = { buffer: decrypted.buffer, contentType: inferred.contentType, filename: inferred.filename };
347
+ bodyParts.push(`[${t}]`);
348
+ continue;
349
+ } catch (err) {
350
+ target.runtime.error?.(`Failed to decrypt mixed ${t}: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`);
351
+ recordOperationalIssue({
352
+ category: "media-decrypt-failed",
353
+ messageId: msg.msgid ? String(msg.msgid) : undefined,
354
+ summary: `mixed ${t} decrypt failed url=${url}`,
355
+ raw: { transport: "bot-webhook", envelopeType: "json", body: msg },
356
+ error: err instanceof Error ? err.message : String(err),
357
+ });
358
+ const errorMessage = typeof err === "object" && err ? `${(err as any).message}${(err as any).cause ? ` (cause: ${String((err as any).cause)})` : ""}` : String(err);
359
+ bodyParts.push(`[${t}] (decryption failed: ${errorMessage})`);
360
+ continue;
361
+ }
362
+ }
363
+ }
364
+ bodyParts.push(`[${t}]`);
365
+ }
366
+ return { body: bodyParts.join("\n"), media: foundMedia };
367
+ }
368
+ }
369
+
370
+ return { body: buildInboundBody(msg) };
371
+ }
@@ -0,0 +1,5 @@
1
+ import { resolveDerivedPathSummary } from "../../config/index.js";
2
+
3
+ export function resolveBotWebhookPaths(accountId: string): string[] {
4
+ return resolveDerivedPathSummary(accountId).botWebhook;
5
+ }
@@ -0,0 +1,89 @@
1
+ import type { WecomBotInboundMessage as WecomInboundMessage, WecomInboundQuote } from "../../types/index.js";
2
+
3
+ export type BotInboundProcessDecision = {
4
+ shouldProcess: boolean;
5
+ reason: string;
6
+ senderUserId?: string;
7
+ chatId?: string;
8
+ };
9
+
10
+ export function resolveWecomSenderUserId(msg: WecomInboundMessage): string | undefined {
11
+ const direct = msg.from?.userid?.trim();
12
+ if (direct) return direct;
13
+ const legacy = String((msg as any).fromuserid ?? (msg as any).from_userid ?? (msg as any).fromUserId ?? "").trim();
14
+ return legacy || undefined;
15
+ }
16
+
17
+ export function shouldProcessBotInboundMessage(msg: WecomInboundMessage): BotInboundProcessDecision {
18
+ const senderUserId = resolveWecomSenderUserId(msg)?.trim();
19
+ if (!senderUserId) {
20
+ return { shouldProcess: false, reason: "missing_sender" };
21
+ }
22
+ if (senderUserId.toLowerCase() === "sys") {
23
+ return { shouldProcess: false, reason: "system_sender" };
24
+ }
25
+
26
+ const chatType = String(msg.chattype ?? "").trim().toLowerCase();
27
+ if (chatType === "group") {
28
+ const chatId = msg.chatid?.trim();
29
+ if (!chatId) {
30
+ return { shouldProcess: false, reason: "missing_chatid", senderUserId };
31
+ }
32
+ return { shouldProcess: true, reason: "user_message", senderUserId, chatId };
33
+ }
34
+
35
+ return { shouldProcess: true, reason: "user_message", senderUserId, chatId: senderUserId };
36
+ }
37
+
38
+ function formatQuote(quote: WecomInboundQuote): string {
39
+ const type = quote.msgtype ?? "";
40
+ if (type === "text") return quote.text?.content || "";
41
+ if (type === "image") return `[引用: 图片] ${quote.image?.url || ""}`;
42
+ if (type === "mixed" && quote.mixed?.msg_item) {
43
+ const items = quote.mixed.msg_item
44
+ .map((item) => {
45
+ if (item.msgtype === "text") return item.text?.content;
46
+ if (item.msgtype === "image") return `[图片] ${item.image?.url || ""}`;
47
+ return "";
48
+ })
49
+ .filter(Boolean)
50
+ .join(" ");
51
+ return `[引用: 图文] ${items}`;
52
+ }
53
+ if (type === "voice") return `[引用: 语音] ${quote.voice?.content || ""}`;
54
+ if (type === "file") return `[引用: 文件] ${quote.file?.url || ""}`;
55
+ return "";
56
+ }
57
+
58
+ export function buildInboundBody(msg: WecomInboundMessage): string {
59
+ let body = "";
60
+ const msgtype = String(msg.msgtype ?? "").toLowerCase();
61
+
62
+ if (msgtype === "text") body = (msg as any).text?.content || "";
63
+ else if (msgtype === "voice") body = (msg as any).voice?.content || "[voice]";
64
+ else if (msgtype === "mixed") {
65
+ const items = (msg as any).mixed?.msg_item;
66
+ if (Array.isArray(items)) {
67
+ body = items
68
+ .map((item: any) => {
69
+ const t = String(item?.msgtype ?? "").toLowerCase();
70
+ if (t === "text") return item?.text?.content || "";
71
+ if (t === "image") return `[image] ${item?.image?.url || ""}`;
72
+ return `[${t || "item"}]`;
73
+ })
74
+ .filter(Boolean)
75
+ .join("\n");
76
+ } else body = "[mixed]";
77
+ } else if (msgtype === "image") body = `[image] ${(msg as any).image?.url || ""}`;
78
+ else if (msgtype === "file") body = `[file] ${(msg as any).file?.url || ""}`;
79
+ else if (msgtype === "event") body = `[event] ${(msg as any).event?.eventtype || ""}`;
80
+ else if (msgtype === "stream") body = `[stream_refresh] ${(msg as any).stream?.id || ""}`;
81
+ else body = msgtype ? `[${msgtype}]` : "";
82
+
83
+ const quote = (msg as any).quote;
84
+ if (quote) {
85
+ const quoteText = formatQuote(quote).trim();
86
+ if (quoteText) body += `\n\n> ${quoteText}`;
87
+ }
88
+ return body;
89
+ }
@@ -0,0 +1,148 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+
3
+ import type { ResolvedBotAccount, WecomBotInboundMessage as WecomInboundMessage } from "../../types/index.js";
4
+ import { LIMITS } from "../../monitor/state.js";
5
+ import { computeWecomMsgSignature, encryptWecomPlaintext } from "../../crypto.js";
6
+ import type { StreamState } from "../../types/legacy-stream.js";
7
+ import type { WecomWebhookTarget } from "../../types/runtime-context.js";
8
+
9
+ function truncateUtf8Bytes(text: string, maxBytes: number): string {
10
+ const buf = Buffer.from(text, "utf8");
11
+ if (buf.length <= maxBytes) return text;
12
+ const slice = buf.subarray(buf.length - maxBytes);
13
+ return slice.toString("utf8");
14
+ }
15
+
16
+ export function jsonOk(res: ServerResponse, body: unknown): void {
17
+ res.statusCode = 200;
18
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
19
+ res.end(JSON.stringify(body));
20
+ }
21
+
22
+ export async function readBotWebhookJsonBody(req: IncomingMessage, maxBytes: number) {
23
+ const chunks: Buffer[] = [];
24
+ let total = 0;
25
+ return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
26
+ req.on("data", (chunk: Buffer) => {
27
+ total += chunk.length;
28
+ if (total > maxBytes) {
29
+ resolve({ ok: false, error: "payload too large" });
30
+ req.destroy();
31
+ return;
32
+ }
33
+ chunks.push(chunk);
34
+ });
35
+ req.on("end", () => {
36
+ try {
37
+ const raw = Buffer.concat(chunks).toString("utf8");
38
+ if (!raw.trim()) {
39
+ resolve({ ok: false, error: "empty payload" });
40
+ return;
41
+ }
42
+ resolve({ ok: true, value: JSON.parse(raw) as unknown });
43
+ } catch (err) {
44
+ resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
45
+ }
46
+ });
47
+ req.on("error", (err) => {
48
+ resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
49
+ });
50
+ });
51
+ }
52
+
53
+ export function buildEncryptedBotWebhookReply(params: {
54
+ account: ResolvedBotAccount;
55
+ plaintextJson: unknown;
56
+ nonce: string;
57
+ timestamp: string;
58
+ }): { encrypt: string; msgsignature: string; timestamp: string; nonce: string } {
59
+ const plaintext = JSON.stringify(params.plaintextJson ?? {});
60
+ const encrypt = encryptWecomPlaintext({
61
+ encodingAESKey: params.account.encodingAESKey ?? "",
62
+ receiveId: params.account.receiveId ?? "",
63
+ plaintext,
64
+ });
65
+ const msgsignature = computeWecomMsgSignature({
66
+ token: params.account.token ?? "",
67
+ timestamp: params.timestamp,
68
+ nonce: params.nonce,
69
+ encrypt,
70
+ });
71
+ return {
72
+ encrypt,
73
+ msgsignature,
74
+ timestamp: params.timestamp,
75
+ nonce: params.nonce,
76
+ };
77
+ }
78
+
79
+ export function resolveBotIdentitySet(target: WecomWebhookTarget): Set<string> {
80
+ const ids = new Set<string>();
81
+ const single = target.account.config.aibotid?.trim();
82
+ if (single) ids.add(single);
83
+ for (const botId of target.account.config.botIds ?? []) {
84
+ const normalized = String(botId ?? "").trim();
85
+ if (normalized) ids.add(normalized);
86
+ }
87
+ return ids;
88
+ }
89
+
90
+ export function buildStreamPlaceholderReply(params: {
91
+ streamId: string;
92
+ placeholderContent?: string;
93
+ }): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
94
+ const content = params.placeholderContent?.trim() || "1";
95
+ return {
96
+ msgtype: "stream",
97
+ stream: {
98
+ id: params.streamId,
99
+ finish: false,
100
+ content,
101
+ },
102
+ };
103
+ }
104
+
105
+ export function buildStreamTextPlaceholderReply(params: {
106
+ streamId: string;
107
+ content: string;
108
+ }): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
109
+ return {
110
+ msgtype: "stream",
111
+ stream: {
112
+ id: params.streamId,
113
+ finish: false,
114
+ content: params.content.trim() || "1",
115
+ },
116
+ };
117
+ }
118
+
119
+ export function buildStreamReplyFromState(state: StreamState): {
120
+ msgtype: "stream";
121
+ stream: { id: string; finish: boolean; content: string; msg_item?: Array<{ msgtype: string; image: { base64: string; md5: string } }> };
122
+ } {
123
+ const content = truncateUtf8Bytes(state.content, LIMITS.STREAM_MAX_BYTES);
124
+ return {
125
+ msgtype: "stream",
126
+ stream: {
127
+ id: state.streamId,
128
+ finish: state.finished,
129
+ content,
130
+ ...(state.finished && state.images?.length
131
+ ? {
132
+ msg_item: state.images.map((img) => ({
133
+ msgtype: "image",
134
+ image: { base64: img.base64, md5: img.md5 },
135
+ })),
136
+ }
137
+ : {}),
138
+ },
139
+ };
140
+ }
141
+
142
+ export function parseWecomPlainMessage(raw: string): WecomInboundMessage {
143
+ const parsed = JSON.parse(raw) as unknown;
144
+ if (!parsed || typeof parsed !== "object") {
145
+ return {};
146
+ }
147
+ return parsed as WecomInboundMessage;
148
+ }
@@ -0,0 +1,15 @@
1
+ import type { ReplyContext } from "../../types/index.js";
2
+
3
+ export function createBotWebhookReplyContext(params: {
4
+ accountId: string;
5
+ responseUrl?: string;
6
+ raw: ReplyContext["raw"];
7
+ }): ReplyContext {
8
+ return {
9
+ transport: "bot-webhook",
10
+ accountId: params.accountId,
11
+ responseUrl: params.responseUrl,
12
+ passiveWindowMs: 5_000,
13
+ raw: params.raw,
14
+ };
15
+ }