@yaoqi10012/wechat-kf 0.3.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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/dist/accounts.d.ts +38 -0
  3. package/dist/accounts.js +247 -0
  4. package/dist/accounts.js.map +1 -0
  5. package/dist/api.d.ts +58 -0
  6. package/dist/api.js +200 -0
  7. package/dist/api.js.map +1 -0
  8. package/dist/bot.d.ts +37 -0
  9. package/dist/bot.js +672 -0
  10. package/dist/bot.js.map +1 -0
  11. package/dist/channel.d.ts +14 -0
  12. package/dist/channel.js +320 -0
  13. package/dist/channel.js.map +1 -0
  14. package/dist/config-schema.d.ts +56 -0
  15. package/dist/config-schema.js +39 -0
  16. package/dist/config-schema.js.map +1 -0
  17. package/dist/constants.d.ts +41 -0
  18. package/dist/constants.js +51 -0
  19. package/dist/constants.js.map +1 -0
  20. package/dist/crypto.d.ts +18 -0
  21. package/dist/crypto.js +81 -0
  22. package/dist/crypto.js.map +1 -0
  23. package/dist/fs-utils.d.ts +7 -0
  24. package/dist/fs-utils.js +13 -0
  25. package/dist/fs-utils.js.map +1 -0
  26. package/dist/monitor.d.ts +31 -0
  27. package/dist/monitor.js +80 -0
  28. package/dist/monitor.js.map +1 -0
  29. package/dist/outbound.d.ts +32 -0
  30. package/dist/outbound.js +411 -0
  31. package/dist/outbound.js.map +1 -0
  32. package/dist/reply-dispatcher.d.ts +36 -0
  33. package/dist/reply-dispatcher.js +216 -0
  34. package/dist/reply-dispatcher.js.map +1 -0
  35. package/dist/runtime.d.ts +12 -0
  36. package/dist/runtime.js +23 -0
  37. package/dist/runtime.js.map +1 -0
  38. package/dist/send-utils.d.ts +52 -0
  39. package/dist/send-utils.js +217 -0
  40. package/dist/send-utils.js.map +1 -0
  41. package/dist/token.d.ts +8 -0
  42. package/dist/token.js +61 -0
  43. package/dist/token.js.map +1 -0
  44. package/dist/types.d.ts +236 -0
  45. package/dist/types.js +3 -0
  46. package/dist/types.js.map +1 -0
  47. package/dist/unicode-format.d.ts +26 -0
  48. package/dist/unicode-format.js +157 -0
  49. package/dist/unicode-format.js.map +1 -0
  50. package/dist/webhook.d.ts +22 -0
  51. package/dist/webhook.js +148 -0
  52. package/dist/webhook.js.map +1 -0
  53. package/dist/wechat-kf-directives.d.ts +157 -0
  54. package/dist/wechat-kf-directives.js +576 -0
  55. package/dist/wechat-kf-directives.js.map +1 -0
  56. package/openclaw.plugin.json +31 -0
  57. package/package.json +92 -0
package/dist/bot.js ADDED
@@ -0,0 +1,672 @@
1
+ /**
2
+ * Message processing — pull messages via sync_msg and dispatch to OpenClaw agent.
3
+ *
4
+ * Architecture:
5
+ * - Each openKfId is an independent account with its own cursor and session
6
+ * - sync_msg is called with open_kfid filter to only pull messages for that kf account
7
+ * - Plugin layer: download media, save via MediaPaths/MediaTypes
8
+ * - OpenClaw runner: handles media understanding (transcription, vision, etc.)
9
+ */
10
+ import { mkdir, readFile } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { getChannelConfig, registerKfId, resolveAccount } from "./accounts.js";
13
+ import { downloadMedia, sendTextMessage, syncMessages } from "./api.js";
14
+ import { CHANNEL_ID, cursorFileName, formatError, logTag, MAX_MESSAGE_AGE_S } from "./constants.js";
15
+ import { atomicWriteFile } from "./fs-utils.js";
16
+ import { setPairingKfId } from "./monitor.js";
17
+ import { createReplyDispatcher } from "./reply-dispatcher.js";
18
+ import { getRuntime } from "./runtime.js";
19
+ import { contentTypeToExt, detectImageMime } from "./send-utils.js";
20
+ /**
21
+ * Backward-compatible wrapper for readAllowFromStore.
22
+ * OpenClaw >=2026.2.26 changed from positional args to object params.
23
+ * Try new API first; fall back to old positional API if result is empty.
24
+ */
25
+ async function readAllowFromStoreCompat(core, channelId, accountId) {
26
+ const read = core.channel.pairing.readAllowFromStore;
27
+ const result = await read({ channel: channelId, accountId }).catch(() => []);
28
+ if (result.length > 0)
29
+ return result;
30
+ return read(channelId).catch(() => []);
31
+ }
32
+ // ── Per-kfId async mutex ──
33
+ // Ensures that concurrent calls to handleWebhookEvent for the same openKfId
34
+ // (e.g. from webhook + polling simultaneously) are serialized.
35
+ const kfLocks = new Map();
36
+ // ── Message deduplication ──
37
+ // Tracks recently-processed msgids to avoid dispatching the same message twice,
38
+ // even if sync_msg returns overlapping batches from concurrent paths.
39
+ const processedMsgIds = new Set();
40
+ const DEDUP_MAX_SIZE = 10_000;
41
+ function isDuplicate(msgid) {
42
+ if (processedMsgIds.has(msgid))
43
+ return true;
44
+ if (processedMsgIds.size >= DEDUP_MAX_SIZE) {
45
+ // Evict the oldest half (Set preserves insertion order)
46
+ const entries = [...processedMsgIds];
47
+ processedMsgIds.clear();
48
+ for (const id of entries.slice(entries.length >>> 1)) {
49
+ processedMsgIds.add(id);
50
+ }
51
+ }
52
+ processedMsgIds.add(msgid);
53
+ return false;
54
+ }
55
+ // ── Inbound debounce ──
56
+ const DEFAULT_DEBOUNCE_MS = 2000;
57
+ let debouncer = null;
58
+ function getOrCreateDebouncer(ctx) {
59
+ if (debouncer)
60
+ return debouncer;
61
+ const core = getRuntime();
62
+ const channelConfig = getChannelConfig(ctx.cfg);
63
+ const inbound = ctx.cfg.messages;
64
+ const hasExplicitDebounce = typeof inbound?.inbound?.debounceMs === "number" || typeof inbound?.inbound?.byChannel?.[CHANNEL_ID] === "number";
65
+ const debounceMs = hasExplicitDebounce
66
+ ? core.channel.debounce.resolveInboundDebounceMs({ cfg: ctx.cfg, channel: CHANNEL_ID })
67
+ : (channelConfig.debounceMs ?? DEFAULT_DEBOUNCE_MS);
68
+ debouncer = core.channel.debounce.createInboundDebouncer({
69
+ debounceMs,
70
+ buildKey: (item) => `${CHANNEL_ID}:${item.openKfId}:${item.externalUserId}`,
71
+ shouldDebounce: (item) => {
72
+ return !core.channel.text.hasControlCommand(item.text, item.ctx.cfg);
73
+ },
74
+ onFlush: async (items) => {
75
+ if (items.length === 0)
76
+ return;
77
+ if (items.length === 1) {
78
+ await dispatchPrepared(items[0]);
79
+ return;
80
+ }
81
+ await dispatchCombined(items);
82
+ },
83
+ onError: (err, items) => {
84
+ items[0]?.ctx.log?.error(`${logTag(items[0].openKfId)} debounce flush failed: ${formatError(err)}`);
85
+ },
86
+ });
87
+ return debouncer;
88
+ }
89
+ /** Exposed for testing only — do not use in production code. */
90
+ export const _testing = {
91
+ kfLocks,
92
+ processedMsgIds,
93
+ isDuplicate,
94
+ DEDUP_MAX_SIZE,
95
+ handleEvent,
96
+ drainToLatestCursor,
97
+ resetState() {
98
+ kfLocks.clear();
99
+ processedMsgIds.clear();
100
+ debouncer = null;
101
+ },
102
+ };
103
+ // ── Cursor persistence (per kfid) ──
104
+ async function loadCursor(stateDir, kfId) {
105
+ try {
106
+ return (await readFile(join(stateDir, cursorFileName(kfId)), "utf8")).trim();
107
+ }
108
+ catch {
109
+ return "";
110
+ }
111
+ }
112
+ let dirCreated = false;
113
+ async function saveCursor(stateDir, kfId, cursor) {
114
+ if (!dirCreated) {
115
+ await mkdir(stateDir, { recursive: true });
116
+ dirCreated = true;
117
+ }
118
+ await atomicWriteFile(join(stateDir, cursorFileName(kfId)), cursor);
119
+ }
120
+ // ── Message text extraction ──
121
+ // Descriptions of non-text messages injected into the AI agent's context.
122
+ // Kept in Chinese because end-users are Chinese WeChat users and the agent
123
+ // replies in Chinese. These are NOT displayed to end-users directly.
124
+ function extractText(msg) {
125
+ switch (msg.msgtype) {
126
+ case "text": {
127
+ const textContent = msg.text?.content ?? "";
128
+ const menuId = msg.text?.menu_id;
129
+ return menuId ? `${textContent} [menu_id: ${menuId}]` : textContent;
130
+ }
131
+ case "image":
132
+ return "[用户发送了一张图片]";
133
+ case "voice":
134
+ return "[用户发送了一段语音]";
135
+ case "video":
136
+ return "[用户发送了一段视频]";
137
+ case "file":
138
+ return "[用户发送了一个文件]";
139
+ case "location": {
140
+ const loc = msg.location;
141
+ const parts = [loc?.name, loc?.address].filter(Boolean).join(" ");
142
+ const coords = loc?.latitude != null && loc?.longitude != null ? ` (${loc.latitude}, ${loc.longitude})` : "";
143
+ return parts ? `[位置: ${parts}${coords}]` : coords ? `[位置:${coords}]` : "[位置]";
144
+ }
145
+ case "link": {
146
+ const lk = msg.link;
147
+ const linkParts = [lk?.title, lk?.desc].filter(Boolean).join(" - ");
148
+ const url = lk?.url ?? "";
149
+ const pic = lk?.pic_url ? ` pic_url: ${lk.pic_url}` : "";
150
+ return `[链接: ${linkParts} ${url}${pic}]`;
151
+ }
152
+ case "merged_msg": {
153
+ const merged = msg.merged_msg;
154
+ if (!merged)
155
+ return "[转发的聊天记录]";
156
+ const title = merged.title ?? "聊天记录";
157
+ const items = Array.isArray(merged.item) ? merged.item : [];
158
+ const lines = items.map((item) => {
159
+ const sender = item.sender_name ?? "未知";
160
+ const timeStr = item.send_time
161
+ ? ` (${new Date(item.send_time * 1000).toLocaleString("zh-CN", { hour12: false })})`
162
+ : "";
163
+ let content = "";
164
+ try {
165
+ const parsed = JSON.parse(item.msg_content ?? "{}");
166
+ const parsedText = parsed.text;
167
+ if (parsedText?.content)
168
+ content = parsedText.content;
169
+ else if (parsed.image)
170
+ content = "[图片]";
171
+ else if (parsed.voice)
172
+ content = "[语音]";
173
+ else if (parsed.video)
174
+ content = "[视频]";
175
+ else if (parsed.file)
176
+ content = "[文件]";
177
+ else if (parsed.link) {
178
+ const lk = parsed.link;
179
+ const linkParts = [lk.title, lk.desc].filter(Boolean).join(" - ");
180
+ const linkUrl = lk.url ?? "";
181
+ content = `[链接: ${linkParts} ${linkUrl}]`.trim();
182
+ }
183
+ else if (parsed.location) {
184
+ const loc = parsed.location;
185
+ const locParts = [loc.name, loc.address].filter(Boolean).join(", ");
186
+ const coords = loc.latitude != null && loc.longitude != null ? ` (${loc.latitude}, ${loc.longitude})` : "";
187
+ content = locParts ? `[位置: ${locParts}${coords}]` : coords ? `[位置:${coords}]` : "[位置]";
188
+ }
189
+ else if (parsed.miniprogram) {
190
+ const mp = parsed.miniprogram;
191
+ content = `[小程序: ${mp.title ?? ""} (${mp.appid ?? ""})]`;
192
+ }
193
+ else if (parsed.channels) {
194
+ const ch = parsed.channels;
195
+ content = `[视频号: ${[ch.nickname, ch.title].filter(Boolean).join(" - ")}]`;
196
+ }
197
+ else
198
+ content = `[${item.msgtype ?? parsed.msgtype ?? "未知类型"}]`;
199
+ }
200
+ catch {
201
+ content = item.msg_content ?? "";
202
+ }
203
+ return `${sender}${timeStr}: ${content}`;
204
+ });
205
+ return `[转发的聊天记录: ${title}]\n${lines.join("\n")}`;
206
+ }
207
+ case "channels": {
208
+ const ch = msg.channels;
209
+ const typeMap = { 1: "视频号动态", 2: "视频号直播", 3: "视频号名片" };
210
+ const typeName = ch?.sub_type != null ? (typeMap[ch.sub_type] ?? "视频号消息") : "视频号消息";
211
+ return `[${typeName}] ${ch?.nickname ?? ""}: ${ch?.title ?? ""}`;
212
+ }
213
+ case "miniprogram": {
214
+ const mp = msg.miniprogram;
215
+ const mpParts = [`[小程序] ${mp?.title ?? ""} (appid: ${mp?.appid ?? ""})`];
216
+ if (mp?.pagepath)
217
+ mpParts.push(`pagepath: ${mp.pagepath}`);
218
+ if (mp?.thumb_media_id)
219
+ mpParts.push(`thumb_media_id: ${mp.thumb_media_id}`);
220
+ return mpParts.join(", ");
221
+ }
222
+ case "msgmenu": {
223
+ const menu = msg.msgmenu;
224
+ const head = menu?.head_content ?? "";
225
+ const menuItems = Array.isArray(menu?.list) ? menu.list.map((item) => item.content ?? item.id).join(", ") : "";
226
+ return head ? `${head} [选项: ${menuItems}]` : `[菜单消息: ${menuItems}]`;
227
+ }
228
+ case "business_card":
229
+ return `[名片] userid: ${msg.business_card?.userid ?? ""}`;
230
+ case "channels_shop_product": {
231
+ const p = msg.channels_shop_product;
232
+ const productParts = ["[视频号商品]"];
233
+ if (p?.title)
234
+ productParts.push(p.title);
235
+ if (p?.sales_price)
236
+ productParts.push(`价格: ${p.sales_price}`);
237
+ if (p?.shop_nickname)
238
+ productParts.push(`店铺: ${p.shop_nickname}`);
239
+ if (p?.product_id)
240
+ productParts.push(`商品ID: ${p.product_id}`);
241
+ if (p?.head_image)
242
+ productParts.push(`图片: ${p.head_image}`);
243
+ if (p?.shop_head_image)
244
+ productParts.push(`店铺头像: ${p.shop_head_image}`);
245
+ return productParts.join(" | ");
246
+ }
247
+ case "channels_shop_order": {
248
+ const o = msg.channels_shop_order;
249
+ const orderParts = ["[视频号订单]"];
250
+ if (o?.product_titles)
251
+ orderParts.push(o.product_titles);
252
+ if (o?.price_wording)
253
+ orderParts.push(`金额: ${o.price_wording}`);
254
+ if (o?.state)
255
+ orderParts.push(`状态: ${o.state}`);
256
+ if (o?.shop_nickname)
257
+ orderParts.push(`店铺: ${o.shop_nickname}`);
258
+ if (o?.order_id)
259
+ orderParts.push(`订单ID: ${o.order_id}`);
260
+ if (o?.image_url)
261
+ orderParts.push(`图片: ${o.image_url}`);
262
+ return orderParts.join(" | ");
263
+ }
264
+ case "note":
265
+ return "[用户发送了一条笔记]";
266
+ case "event":
267
+ return null;
268
+ default: {
269
+ const rawJson = JSON.stringify(msg, null, 2);
270
+ return `[未支持的消息类型: ${msg.msgtype}]\n\n原始JSON:\n${rawJson}`;
271
+ }
272
+ }
273
+ }
274
+ // ── Event handling ──
275
+ /** Map known msg_send_fail fail_type codes to human-readable descriptions. */
276
+ function failTypeLabel(failType) {
277
+ switch (failType) {
278
+ case 0:
279
+ return "unknown";
280
+ case 10:
281
+ return "user rejected";
282
+ case 13:
283
+ return "content security (phishing/scam pattern detected)";
284
+ default:
285
+ return "unrecognized";
286
+ }
287
+ }
288
+ async function handleEvent(ctx, _account, msg) {
289
+ const event = msg.event;
290
+ const { log } = ctx;
291
+ const kfId = msg.open_kfid;
292
+ switch (event?.event_type) {
293
+ case "enter_session":
294
+ log?.info(`${logTag(kfId)} user ${msg.external_userid} entered session` +
295
+ (event.welcome_code ? `, welcome_code=${event.welcome_code}` : "") +
296
+ (event.scene ? `, scene=${event.scene}` : ""));
297
+ break;
298
+ case "msg_send_fail":
299
+ log?.error(`${logTag(kfId)} message send failed: msgid=${event.fail_msgid}, type=${event.fail_type} (${failTypeLabel(event.fail_type)})`);
300
+ if (event.fail_type === 13) {
301
+ log?.warn?.(`${logTag(kfId)} content security block: avoid numbered lists (1. 2. 3.) when discussing passwords, API keys, or credentials. Use bullet points or conversational prose instead.`);
302
+ }
303
+ break;
304
+ case "servicer_status_change":
305
+ log?.info(`${logTag(kfId)} servicer status changed: ${event.servicer_userid} -> ${event.status}`);
306
+ break;
307
+ default:
308
+ log?.info(`${logTag(kfId)} unhandled event: ${event?.event_type}`);
309
+ }
310
+ }
311
+ // ── Cold Start Catch-up (Layer 1) ──
312
+ // When there is no cursor (file missing, empty, corrupt), drain all pending
313
+ // messages without dispatching them. This prevents historical message
314
+ // bombardment after cursor loss.
315
+ async function drainToLatestCursor(corpId, appSecret, openKfId, syncToken, stateDir, log) {
316
+ let cursor = "";
317
+ let hasMore = true;
318
+ let totalDrained = 0;
319
+ while (hasMore) {
320
+ const syncReq = { limit: 1000, open_kfid: openKfId };
321
+ if (cursor)
322
+ syncReq.cursor = cursor;
323
+ else if (syncToken)
324
+ syncReq.token = syncToken;
325
+ let resp;
326
+ try {
327
+ resp = await syncMessages(corpId, appSecret, syncReq);
328
+ }
329
+ catch (err) {
330
+ log?.error(`${logTag(openKfId)} drain failed: ${formatError(err)}`);
331
+ return;
332
+ }
333
+ totalDrained += resp.msg_list?.length ?? 0;
334
+ if (resp.next_cursor) {
335
+ cursor = resp.next_cursor;
336
+ await saveCursor(stateDir, openKfId, cursor);
337
+ }
338
+ hasMore = resp.has_more === 1;
339
+ }
340
+ if (totalDrained > 0) {
341
+ log?.info(`${logTag(openKfId)} cold start catch-up: skipped ${totalDrained} messages, cursor saved`);
342
+ }
343
+ }
344
+ // ── Core message handler (per kfid) ──
345
+ export async function handleWebhookEvent(ctx, openKfId, syncToken) {
346
+ // Acquire per-kfId mutex — chains onto any in-flight processing for this kfId
347
+ const prev = kfLocks.get(openKfId) ?? Promise.resolve();
348
+ let release;
349
+ const current = new Promise((r) => {
350
+ release = r;
351
+ });
352
+ kfLocks.set(openKfId, current);
353
+ try {
354
+ await prev;
355
+ await _handleWebhookEventInner(ctx, openKfId, syncToken);
356
+ }
357
+ finally {
358
+ release();
359
+ // Clean up map entry only if no newer caller has replaced it
360
+ if (kfLocks.get(openKfId) === current) {
361
+ kfLocks.delete(openKfId);
362
+ }
363
+ }
364
+ }
365
+ async function _handleWebhookEventInner(ctx, openKfId, syncToken) {
366
+ const { cfg, stateDir, log } = ctx;
367
+ const account = resolveAccount(cfg, openKfId); // kfid as accountId
368
+ const resolvedKfId = account.openKfId ?? openKfId; // recover original case
369
+ const { corpId, appSecret } = account;
370
+ if (!corpId || !appSecret) {
371
+ log?.error(`${logTag()} missing corpId/appSecret`);
372
+ return;
373
+ }
374
+ // Register this kfid as discovered
375
+ await registerKfId(resolvedKfId);
376
+ let cursor = await loadCursor(stateDir, openKfId);
377
+ // Layer 1: Cold Start Catch-up — no cursor means drain without dispatching
378
+ if (!cursor) {
379
+ log?.info(`${logTag(openKfId)} no cursor, draining to current position`);
380
+ await drainToLatestCursor(corpId, appSecret, openKfId, syncToken, stateDir, log);
381
+ return;
382
+ }
383
+ // Normal incremental pull — cursor is always present at this point
384
+ let hasMore = true;
385
+ while (hasMore) {
386
+ const syncReq = {
387
+ limit: 1000,
388
+ open_kfid: resolvedKfId, // Only pull messages for this kf account
389
+ cursor,
390
+ };
391
+ let resp;
392
+ try {
393
+ resp = await syncMessages(corpId, appSecret, syncReq);
394
+ }
395
+ catch (err) {
396
+ log?.error(`${logTag(openKfId)} sync_msg failed: ${formatError(err)}`);
397
+ return;
398
+ }
399
+ for (const msg of resp.msg_list ?? []) {
400
+ log?.debug?.(`${logTag(openKfId)} raw_msg ${msg.msgid} type=${msg.msgtype}: ${JSON.stringify(msg)}`);
401
+ // Handle event messages (any origin) before normal message processing
402
+ if (msg.msgtype === "event") {
403
+ await handleEvent(ctx, account, msg);
404
+ continue;
405
+ }
406
+ if (msg.origin !== 3) {
407
+ log?.debug?.(`${logTag(openKfId)} skipping non-customer msg ${msg.msgid} (origin=${msg.origin})`);
408
+ continue;
409
+ }
410
+ // Layer 2: skip stale messages to prevent bombardment from corrupt cursors
411
+ const messageAgeS = Math.floor(Date.now() / 1000) - msg.send_time;
412
+ if (messageAgeS > MAX_MESSAGE_AGE_S) {
413
+ log?.debug?.(`${logTag(openKfId)} skipping stale msg ${msg.msgid} (age=${messageAgeS}s)`);
414
+ continue;
415
+ }
416
+ // Dedup: skip messages we have already processed
417
+ if (isDuplicate(msg.msgid)) {
418
+ log?.debug?.(`${logTag(openKfId)} skipping duplicate msg ${msg.msgid}`);
419
+ continue;
420
+ }
421
+ const text = extractText(msg);
422
+ if (text === null || text === "") {
423
+ log?.debug?.(`${logTag(openKfId)} skipping empty text for msg ${msg.msgid} (type=${msg.msgtype})`);
424
+ continue;
425
+ }
426
+ try {
427
+ const prepared = await prepareMessage(ctx, account, msg, text);
428
+ if (prepared) {
429
+ await getOrCreateDebouncer(ctx).enqueue(prepared);
430
+ }
431
+ }
432
+ catch (err) {
433
+ log?.error(`${logTag(openKfId)} dispatch error for msg ${msg.msgid}: ${formatError(err)}`);
434
+ }
435
+ }
436
+ // P1-02: Save cursor AFTER all messages in the batch are processed
437
+ // (at-least-once delivery). If the process crashes mid-batch, the
438
+ // cursor has not advanced and the batch will be re-pulled on restart.
439
+ // P1-01 msgid dedup ensures replayed messages are not dispatched twice.
440
+ if (resp.next_cursor) {
441
+ cursor = resp.next_cursor;
442
+ await saveCursor(stateDir, openKfId, cursor);
443
+ }
444
+ hasMore = resp.has_more === 1;
445
+ }
446
+ }
447
+ // ── Prepare + dispatch message to agent ──
448
+ async function prepareMessage(ctx, account, msg, text) {
449
+ const { cfg, log } = ctx;
450
+ // ── DM policy check ──
451
+ const channelConfig = getChannelConfig(cfg);
452
+ const dmPolicy = channelConfig.dmPolicy ?? "open";
453
+ const externalUserId = msg.external_userid;
454
+ if (dmPolicy === "disabled") {
455
+ log?.info(`${logTag()} drop DM (dmPolicy: disabled)`);
456
+ return null;
457
+ }
458
+ const core = getRuntime();
459
+ if (dmPolicy !== "open") {
460
+ const configAllowFrom = channelConfig.allowFrom ?? [];
461
+ const storeAllowFrom = await readAllowFromStoreCompat(core, CHANNEL_ID, account.accountId);
462
+ const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
463
+ const allowed = effectiveAllowFrom.includes(externalUserId);
464
+ if (!allowed) {
465
+ if (dmPolicy === "pairing") {
466
+ setPairingKfId(externalUserId, msg.open_kfid);
467
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
468
+ channel: CHANNEL_ID,
469
+ accountId: account.accountId,
470
+ id: externalUserId,
471
+ meta: { openKfId: msg.open_kfid },
472
+ });
473
+ if (created && account.corpId && account.appSecret) {
474
+ log?.info(`${logTag()} pairing request sender=${externalUserId}`);
475
+ try {
476
+ await sendTextMessage(account.corpId, account.appSecret, externalUserId, msg.open_kfid, core.channel.pairing.buildPairingReply({
477
+ channel: CHANNEL_ID,
478
+ idLine: `Your WeChat KF external_userid: ${externalUserId}`,
479
+ code,
480
+ }));
481
+ }
482
+ catch (err) {
483
+ log?.error(`${logTag()} pairing reply failed for ${externalUserId}: ${formatError(err)}`);
484
+ }
485
+ }
486
+ }
487
+ else {
488
+ log?.info(`${logTag()} blocked sender ${externalUserId} (dmPolicy: ${dmPolicy})`);
489
+ }
490
+ return null;
491
+ }
492
+ }
493
+ const kfId = msg.open_kfid;
494
+ // Download media
495
+ const mediaPaths = [];
496
+ const mediaTypes = [];
497
+ if (account.corpId && account.appSecret) {
498
+ const mediaId = msg.image?.media_id || msg.voice?.media_id || msg.video?.media_id || msg.file?.media_id;
499
+ if (mediaId) {
500
+ try {
501
+ const { buffer, contentType } = await downloadMedia(account.corpId, account.appSecret, mediaId);
502
+ let mime;
503
+ let filename;
504
+ if (msg.msgtype === "image") {
505
+ // Detect actual image format: magic bytes first, content-type fallback
506
+ const detected = detectImageMime(buffer);
507
+ if (detected) {
508
+ mime = detected;
509
+ }
510
+ else {
511
+ const ct = contentType.split(";")[0].trim();
512
+ mime = ct.startsWith("image/") ? ct : "image/jpeg";
513
+ }
514
+ const ext = contentTypeToExt(mime) || ".jpg";
515
+ filename = `wechat_image_${msg.msgid}${ext}`;
516
+ }
517
+ else {
518
+ const staticMap = {
519
+ voice: ["audio/amr", `wechat_voice_${msg.msgid}.amr`],
520
+ video: ["video/mp4", `wechat_video_${msg.msgid}.mp4`],
521
+ file: ["application/octet-stream", `wechat_file_${msg.msgid}`],
522
+ };
523
+ [mime, filename] = staticMap[msg.msgtype] ?? ["application/octet-stream", `wechat_media_${msg.msgid}`];
524
+ }
525
+ const saved = await core.channel.media.saveMediaBuffer(buffer, mime, "inbound", undefined, filename);
526
+ mediaPaths.push(saved.path);
527
+ mediaTypes.push(mime);
528
+ log?.info(`${logTag(kfId)} saved media: ${saved.path} (${mime})`);
529
+ }
530
+ catch (err) {
531
+ log?.error(`${logTag(kfId)} failed to save media ${mediaId}: ${formatError(err)}`);
532
+ }
533
+ }
534
+ }
535
+ // Download media from merged_msg items
536
+ if (msg.msgtype === "merged_msg" && msg.merged_msg?.item && account.corpId && account.appSecret) {
537
+ for (let idx = 0; idx < msg.merged_msg.item.length; idx++) {
538
+ const item = msg.merged_msg.item[idx];
539
+ try {
540
+ const parsed = JSON.parse(item.msg_content ?? "{}");
541
+ const itemMediaId = parsed.image?.media_id ||
542
+ parsed.voice?.media_id ||
543
+ parsed.video?.media_id ||
544
+ parsed.file?.media_id;
545
+ if (!itemMediaId)
546
+ continue;
547
+ const itemType = parsed.msgtype ?? item.msgtype ?? "media";
548
+ const { buffer, contentType } = await downloadMedia(account.corpId, account.appSecret, itemMediaId);
549
+ let mime;
550
+ let filename;
551
+ if (itemType === "image") {
552
+ const detected = detectImageMime(buffer);
553
+ if (detected) {
554
+ mime = detected;
555
+ }
556
+ else {
557
+ const ct = contentType.split(";")[0].trim();
558
+ mime = ct.startsWith("image/") ? ct : "image/jpeg";
559
+ }
560
+ const ext = contentTypeToExt(mime) || ".jpg";
561
+ filename = `wechat_merged_image_${msg.msgid}_${idx}${ext}`;
562
+ }
563
+ else {
564
+ const staticMap = {
565
+ voice: ["audio/amr", ".amr"],
566
+ video: ["video/mp4", ".mp4"],
567
+ file: ["application/octet-stream", ""],
568
+ };
569
+ const [m, ext] = staticMap[itemType] ?? ["application/octet-stream", ""];
570
+ mime = m;
571
+ filename = `wechat_merged_${itemType}_${msg.msgid}_${idx}${ext}`;
572
+ }
573
+ const saved = await core.channel.media.saveMediaBuffer(buffer, mime, "inbound", undefined, filename);
574
+ mediaPaths.push(saved.path);
575
+ mediaTypes.push(mime);
576
+ log?.info(`${logTag(kfId)} saved merged_msg media[${idx}]: ${saved.path} (${mime})`);
577
+ }
578
+ catch (err) {
579
+ log?.error(`${logTag(kfId)} failed to download merged_msg media item[${idx}]: ${formatError(err)}`);
580
+ }
581
+ }
582
+ }
583
+ return { ctx, account, msg, text, externalUserId, openKfId: kfId, mediaPaths, mediaTypes };
584
+ }
585
+ async function dispatchPrepared(prepared) {
586
+ const { ctx, msg, text, openKfId: kfId, mediaPaths, mediaTypes } = prepared;
587
+ const { cfg, runtime, log } = ctx;
588
+ const core = getRuntime();
589
+ const from = `${CHANNEL_ID}:${msg.external_userid}`;
590
+ const to = `user:${msg.external_userid}`;
591
+ // Route using kfid as accountId
592
+ const route = core.channel.routing.resolveAgentRoute({
593
+ cfg,
594
+ channel: CHANNEL_ID,
595
+ accountId: kfId,
596
+ peer: { kind: "direct", id: msg.external_userid },
597
+ });
598
+ // System event
599
+ const preview = text.replace(/\s+/g, " ").slice(0, 160);
600
+ core.system.enqueueSystemEvent(`WeChat-KF[${kfId}] DM from ${msg.external_userid}: ${preview}`, {
601
+ sessionKey: route.sessionKey,
602
+ contextKey: `${CHANNEL_ID}:message:${msg.msgid}`,
603
+ });
604
+ // Format envelope
605
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
606
+ const body = core.channel.reply.formatAgentEnvelope({
607
+ channel: "WeChat-KF",
608
+ from: msg.external_userid,
609
+ timestamp: new Date(msg.send_time * 1000),
610
+ envelope: envelopeOptions,
611
+ body: text,
612
+ });
613
+ // Build inbound context
614
+ const inboundCtx = core.channel.reply.finalizeInboundContext({
615
+ Body: body,
616
+ RawBody: text,
617
+ CommandBody: text,
618
+ From: from,
619
+ To: to,
620
+ SessionKey: route.sessionKey,
621
+ AccountId: kfId,
622
+ ChatType: "direct",
623
+ SenderName: msg.external_userid,
624
+ SenderId: msg.external_userid,
625
+ Provider: CHANNEL_ID,
626
+ Surface: CHANNEL_ID,
627
+ MessageSid: msg.msgid,
628
+ Timestamp: msg.send_time * 1000,
629
+ WasMentioned: false,
630
+ CommandAuthorized: true,
631
+ OriginatingChannel: CHANNEL_ID,
632
+ OriginatingTo: to,
633
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
634
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
635
+ });
636
+ // Dispatch to agent
637
+ const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcher({
638
+ cfg,
639
+ agentId: route.agentId,
640
+ runtime: runtime ?? {},
641
+ externalUserId: msg.external_userid,
642
+ openKfId: kfId,
643
+ accountId: kfId,
644
+ });
645
+ log?.info(`${logTag(kfId)} dispatching to agent (session=${route.sessionKey})`);
646
+ const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
647
+ ctx: inboundCtx,
648
+ cfg,
649
+ dispatcher,
650
+ replyOptions,
651
+ });
652
+ markDispatchIdle?.();
653
+ log?.info(`${logTag(kfId)} dispatch complete (queuedFinal=${queuedFinal}, replies=${counts?.final})`);
654
+ }
655
+ async function dispatchCombined(items) {
656
+ const last = items[items.length - 1];
657
+ const combinedText = items
658
+ .map((i) => i.text)
659
+ .filter(Boolean)
660
+ .join("\n");
661
+ const combinedMediaPaths = items.flatMap((i) => i.mediaPaths);
662
+ const combinedMediaTypes = items.flatMap((i) => i.mediaTypes);
663
+ last.ctx.log?.info(`${logTag(last.openKfId)} coalescing ${items.length} messages from ${last.externalUserId}`);
664
+ const combined = {
665
+ ...last,
666
+ text: combinedText,
667
+ mediaPaths: combinedMediaPaths,
668
+ mediaTypes: combinedMediaTypes,
669
+ };
670
+ await dispatchPrepared(combined);
671
+ }
672
+ //# sourceMappingURL=bot.js.map