@kyan-du/agent-wechat-openclaw 0.13.3 → 0.14.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 (2) hide show
  1. package/dist/index.js +1090 -62
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1079,11 +1079,11 @@ var require_main = __commonJS({
1079
1079
  });
1080
1080
 
1081
1081
  // index.ts
1082
- import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
1082
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/plugin-entry";
1083
1083
 
1084
1084
  // src/channel.ts
1085
1085
  import { DEFAULT_ACCOUNT_ID as DEFAULT_ACCOUNT_ID2 } from "openclaw/plugin-sdk/account-id";
1086
- import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message";
1086
+ import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-outbound";
1087
1087
 
1088
1088
  // src/types.ts
1089
1089
  function normalizeDmPolicy(policy) {
@@ -1155,6 +1155,9 @@ function getWeChatRuntime() {
1155
1155
  return runtime;
1156
1156
  }
1157
1157
 
1158
+ // src/monitor.ts
1159
+ import { unlink } from "node:fs/promises";
1160
+
1158
1161
  // ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
1159
1162
  var external_exports = {};
1160
1163
  __export(external_exports, {
@@ -5251,6 +5254,23 @@ var chatSchema = external_exports.object({
5251
5254
  unreadCount: external_exports.number().int().nonnegative(),
5252
5255
  isGroup: external_exports.boolean()
5253
5256
  });
5257
+ var groupMemberSchema = external_exports.object({
5258
+ memberId: external_exports.string().min(1),
5259
+ displayName: external_exports.string().min(1),
5260
+ groupAlias: external_exports.string().optional(),
5261
+ nickName: external_exports.string().optional()
5262
+ });
5263
+ var groupMembersPageSchema = external_exports.object({
5264
+ schemaVersion: external_exports.literal(1),
5265
+ items: external_exports.array(groupMemberSchema),
5266
+ nextCursor: external_exports.string().nullable().optional(),
5267
+ errorCode: external_exports.string().optional()
5268
+ });
5269
+ var listGroupMembersParamsSchema = external_exports.object({
5270
+ groupId: external_exports.string().endsWith("@chatroom"),
5271
+ limit: external_exports.number().int().positive().max(100).optional().default(50),
5272
+ cursor: external_exports.string().optional()
5273
+ });
5254
5274
  var listChatsParamsSchema = external_exports.object({
5255
5275
  limit: external_exports.number().int().positive().max(100).optional().default(50),
5256
5276
  offset: external_exports.number().int().nonnegative().optional().default(0)
@@ -5277,6 +5297,22 @@ var openChatResultSchema = external_exports.object({
5277
5297
  error: external_exports.string().optional()
5278
5298
  });
5279
5299
  var idempotencyKeySchema = external_exports.string().min(1).max(128).regex(/^[A-Za-z0-9._:-]+$/);
5300
+ var forwardedMessageNodeSchema = external_exports.lazy(() => external_exports.object({
5301
+ sender: external_exports.string().optional(),
5302
+ senderId: external_exports.string().optional(),
5303
+ timestamp: external_exports.string().optional(),
5304
+ text: external_exports.string().optional(),
5305
+ messageType: external_exports.number().int().optional(),
5306
+ media: external_exports.string().optional(),
5307
+ children: external_exports.array(forwardedMessageNodeSchema),
5308
+ truncated: external_exports.boolean()
5309
+ }));
5310
+ var forwardedMessageTreeSchema = external_exports.object({
5311
+ schemaVersion: external_exports.number().int(),
5312
+ title: external_exports.string().optional(),
5313
+ nodes: external_exports.array(forwardedMessageNodeSchema),
5314
+ truncated: external_exports.boolean()
5315
+ });
5280
5316
  var messageSchema = external_exports.object({
5281
5317
  localId: external_exports.number().int(),
5282
5318
  serverId: external_exports.number(),
@@ -5285,7 +5321,26 @@ var messageSchema = external_exports.object({
5285
5321
  senderName: external_exports.string().optional(),
5286
5322
  type: external_exports.number().int(),
5287
5323
  content: external_exports.string(),
5288
- timestamp: external_exports.string()
5324
+ timestamp: external_exports.string(),
5325
+ forwarded: forwardedMessageTreeSchema.optional()
5326
+ });
5327
+ var mediaReferenceSchema = external_exports.object({
5328
+ localId: external_exports.number().int(),
5329
+ url: external_exports.string().min(1)
5330
+ });
5331
+ var syncReadStateSchema = external_exports.object({
5332
+ unreadCount: external_exports.number().int().nonnegative(),
5333
+ observedAt: external_exports.string().min(1)
5334
+ });
5335
+ var chatSyncPageSchema = external_exports.object({
5336
+ schemaVersion: external_exports.literal(1),
5337
+ chat: chatSchema.optional(),
5338
+ items: external_exports.array(messageSchema),
5339
+ nextCursor: external_exports.string().optional().nullable(),
5340
+ syncToken: external_exports.string(),
5341
+ readState: syncReadStateSchema,
5342
+ media: external_exports.array(mediaReferenceSchema),
5343
+ errorCode: external_exports.string().optional()
5289
5344
  });
5290
5345
  var listMessagesParamsSchema = external_exports.object({
5291
5346
  chatId: external_exports.string().min(1),
@@ -5311,6 +5366,31 @@ var sendParamsSchema = external_exports.object({
5311
5366
  source: external_exports.string().min(1).max(64).regex(/^[A-Za-z0-9._:-]+$/).optional(),
5312
5367
  similarityConfirmed: external_exports.boolean().optional()
5313
5368
  });
5369
+ var deliveryStateSchema = external_exports.enum(["queued", "composing", "submitted", "observed_in_chat", "confirmed", "uncertain", "failed"]);
5370
+ var deliveryCauseSchema = external_exports.enum(["send_accepted", "target_sender_and_payload_match", "observation_confirmed", "target_mismatch", "sender_unverified", "payload_mismatch", "pre_commit_failure", "post_commit_uncertain"]);
5371
+ var deliveryTransitionSchema = external_exports.object({ from: deliveryStateSchema, to: deliveryStateSchema, at: external_exports.string().datetime(), reason: deliveryCauseSchema });
5372
+ var deliveryObservationSchema = external_exports.object({
5373
+ chatId: external_exports.string().trim().min(1).max(512),
5374
+ localId: external_exports.number().int().nonnegative().safe(),
5375
+ serverId: external_exports.number().int().nonnegative().safe(),
5376
+ timestamp: external_exports.string().datetime(),
5377
+ type: external_exports.number().int().nonnegative().safe(),
5378
+ sender: external_exports.string().trim().min(1).max(512),
5379
+ content: external_exports.string().max(1e6)
5380
+ }).strict();
5381
+ var deliveryAttemptSchema = external_exports.object({
5382
+ schemaVersion: external_exports.literal(1),
5383
+ idempotencyKey: idempotencyKeySchema.optional(),
5384
+ senderId: external_exports.string().trim().min(1).max(512),
5385
+ targetChatId: external_exports.string().trim().min(1).max(512),
5386
+ payloadDigest: external_exports.string().regex(/^[a-f0-9]{64}$/),
5387
+ state: deliveryStateSchema,
5388
+ commitAttempted: external_exports.boolean(),
5389
+ createdAt: external_exports.string().datetime(),
5390
+ updatedAt: external_exports.string().datetime(),
5391
+ observedLocalId: external_exports.number().int().nonnegative().optional(),
5392
+ transitions: external_exports.array(deliveryTransitionSchema).max(32)
5393
+ });
5314
5394
  var sendResultSchema = external_exports.object({
5315
5395
  success: external_exports.boolean(),
5316
5396
  messageId: external_exports.string().optional(),
@@ -5323,11 +5403,13 @@ var getMediaParamsSchema = external_exports.object({
5323
5403
  localId: external_exports.number().int()
5324
5404
  });
5325
5405
  var mediaResultSchema = external_exports.object({
5326
- type: external_exports.enum(["image", "emoji", "voice", "video", "unsupported"]),
5406
+ type: external_exports.enum(["image", "emoji", "file", "voice", "video", "pending", "unsupported"]),
5327
5407
  data: external_exports.string().optional(),
5328
5408
  url: external_exports.string().optional(),
5329
5409
  format: external_exports.string(),
5330
- filename: external_exports.string()
5410
+ filename: external_exports.string(),
5411
+ source: external_exports.enum(["original", "thumbnail"]).optional(),
5412
+ errorCode: external_exports.string().optional()
5331
5413
  });
5332
5414
  var agentConfigSchema = external_exports.object({
5333
5415
  maxTurns: external_exports.number().int().positive().default(30),
@@ -5400,11 +5482,12 @@ var WeChatClient = class {
5400
5482
  await throwHttpError(res);
5401
5483
  return res.json();
5402
5484
  }
5403
- async post(path, body) {
5485
+ async post(path, body, signal) {
5404
5486
  const res = await fetch(`${this.base}${path}`, {
5405
5487
  method: "POST",
5406
5488
  headers: this.headers,
5407
- body: body != null ? JSON.stringify(body) : void 0
5489
+ body: body != null ? JSON.stringify(body) : void 0,
5490
+ signal
5408
5491
  });
5409
5492
  if (!res.ok)
5410
5493
  await throwHttpError(res);
@@ -5449,8 +5532,14 @@ var WeChatClient = class {
5449
5532
  async markChatRead(chatId) {
5450
5533
  return this.post(`/api/chats/${encodeURIComponent(chatId)}/mark-read`);
5451
5534
  }
5452
- async openChat(chatId, clearUnreads) {
5453
- return this.post(`/api/chats/${encodeURIComponent(chatId)}/open${qs({ clearUnreads })}`);
5535
+ async openChat(chatId, clearUnreads, signal, executionTimeoutMs) {
5536
+ return this.post(`/api/chats/${encodeURIComponent(chatId)}/open${qs({ clearUnreads, executionTimeoutMs })}`, void 0, signal);
5537
+ }
5538
+ async listGroupMembersPage(groupId, limit, cursor) {
5539
+ const page = groupMembersPageSchema.parse(await this.get(`/api/groups/${encodeURIComponent(groupId)}/members${qs({ limit, cursor })}`));
5540
+ if (page.errorCode)
5541
+ throw new WeChatHttpError(400, "Bad Request", page.errorCode, page.errorCode);
5542
+ return { ...page, nextCursor: page.nextCursor ?? void 0 };
5454
5543
  }
5455
5544
  // ---- Contacts ----
5456
5545
  async listContactsPage(limit, cursor) {
@@ -5475,6 +5564,17 @@ var WeChatClient = class {
5475
5564
  async listMessages(chatId, limit) {
5476
5565
  return (await this.listMessagesPage(chatId, limit)).items;
5477
5566
  }
5567
+ async syncChat(chatId, options = {}) {
5568
+ const raw = await this.get(`/api/sync/${encodeURIComponent(chatId)}${qs({ limit: options.limit, cursor: options.cursor, since: options.since, from: options.from, to: options.to })}`);
5569
+ if (raw && typeof raw === "object" && typeof raw.errorCode === "string") {
5570
+ const errorCode = raw.errorCode;
5571
+ throw new WeChatHttpError(400, "Bad Request", errorCode, errorCode);
5572
+ }
5573
+ const page = chatSyncPageSchema.parse(raw);
5574
+ if (page.errorCode)
5575
+ throw new WeChatHttpError(400, "Bad Request", page.errorCode, page.errorCode);
5576
+ return { ...page, nextCursor: page.nextCursor ?? void 0 };
5577
+ }
5478
5578
  async getMedia(chatId, localId) {
5479
5579
  return this.get(`/api/messages/${encodeURIComponent(chatId)}/media/${localId}`);
5480
5580
  }
@@ -5558,7 +5658,7 @@ var WeChatClient = class {
5558
5658
  };
5559
5659
 
5560
5660
  // src/monitor.ts
5561
- import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
5661
+ import { createChannelMessageReplyPipeline as createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
5562
5662
 
5563
5663
  // src/catch-up.ts
5564
5664
  function recoveryCursor(selection) {
@@ -6073,7 +6173,7 @@ function recordResetRetryFailure(state, chatId, now = Date.now()) {
6073
6173
  });
6074
6174
  persist(state);
6075
6175
  }
6076
- function monitorChatsToProcess(chats, state, now = Date.now()) {
6176
+ function monitorChatsToProcess(chats, state, now = Date.now(), shouldProcessChat = () => true) {
6077
6177
  const result = /* @__PURE__ */ new Map();
6078
6178
  for (const pending of state.pendingMessageScans.values()) {
6079
6179
  const chatId = pending.chat.username ?? pending.chat.id;
@@ -6081,7 +6181,7 @@ function monitorChatsToProcess(chats, state, now = Date.now()) {
6081
6181
  }
6082
6182
  for (const chat of chats) {
6083
6183
  const chatId = chat.username ?? chat.id;
6084
- if (chat.unreadCount > 0 || state.pendingMessageScans.has(chatId)) result.set(chatId, chat);
6184
+ if (shouldProcessChat(chat) && (chat.unreadCount > 0 || state.pendingMessageScans.has(chatId))) result.set(chatId, chat);
6085
6185
  }
6086
6186
  return result;
6087
6187
  }
@@ -6366,6 +6466,9 @@ var MIME_BY_FORMAT = {
6366
6466
  webp: "image/webp",
6367
6467
  gif: "image/gif",
6368
6468
  mp3: "audio/mpeg",
6469
+ wav: "audio/wav",
6470
+ ogg: "audio/ogg",
6471
+ oga: "audio/ogg",
6369
6472
  pdf: "application/pdf",
6370
6473
  doc: "application/msword",
6371
6474
  docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@@ -6374,9 +6477,14 @@ var MIME_BY_FORMAT = {
6374
6477
  ppt: "application/vnd.ms-powerpoint",
6375
6478
  pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
6376
6479
  zip: "application/zip",
6377
- txt: "text/plain"
6480
+ txt: "text/plain",
6481
+ mp4: "video/mp4",
6482
+ webm: "video/webm",
6483
+ mov: "video/quicktime"
6378
6484
  };
6379
6485
  var MAX_IMAGE_BYTES = 20 * 1024 * 1024;
6486
+ var MAX_FILE_BYTES = 25 * 1024 * 1024;
6487
+ var MAX_FILENAME_CHARS = 180;
6380
6488
  var MAX_IMAGE_PIXELS = 4e7;
6381
6489
  var MAX_IMAGE_DIMENSION = 32768;
6382
6490
  var MAX_DECODED_IMAGE_BYTES = MAX_IMAGE_PIXELS * 4;
@@ -6396,6 +6504,29 @@ var SHARP_INPUT_OPTIONS = {
6396
6504
  };
6397
6505
  sharp.cache({ files: 0, items: 0, memory: 32 });
6398
6506
  sharp.concurrency(1);
6507
+ function safeInboundFilename(raw) {
6508
+ const base = raw.replace(/\\/g, "/").split("/").pop() ?? "";
6509
+ const cleaned = [...base].filter((char) => char >= " " && char !== "\x7F" && char !== "/" && char !== "\\").slice(0, MAX_FILENAME_CHARS).join("").replace(/^[.\s]+|[.\s]+$/g, "");
6510
+ return cleaned || void 0;
6511
+ }
6512
+ var SUPPORTED_FILE_FORMATS = /* @__PURE__ */ new Set(["pdf"]);
6513
+ function documentMimeFromMagic(buf) {
6514
+ if (buf.length >= 5 && buf.toString("ascii", 0, 5) === "%PDF-") return "application/pdf";
6515
+ return void 0;
6516
+ }
6517
+ function binaryMimeFromMagic(buf) {
6518
+ if (buf.length >= 3 && buf.subarray(0, 3).equals(Buffer.from("ID3", "ascii"))) return "audio/mpeg";
6519
+ if (buf.length >= 2 && buf[0] === 255 && (buf[1] & 224) === 224) return "audio/mpeg";
6520
+ if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WAVE") return "audio/wav";
6521
+ if (buf.length >= 4 && buf.toString("ascii", 0, 4) === "OggS") return "audio/ogg";
6522
+ if (buf.length >= 12 && buf.toString("ascii", 4, 8) === "ftyp") {
6523
+ const brand = buf.toString("ascii", 8, 12);
6524
+ if (["isom", "iso2", "mp41", "mp42", "avc1"].includes(brand)) return "video/mp4";
6525
+ if (brand === "qt ") return "video/quicktime";
6526
+ }
6527
+ if (buf.length >= 4 && buf.subarray(0, 4).equals(Buffer.from([26, 69, 223, 163]))) return "video/webm";
6528
+ return void 0;
6529
+ }
6399
6530
  function imageMimeFromMagic(buf) {
6400
6531
  if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "image/jpeg";
6401
6532
  if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png";
@@ -6621,53 +6752,753 @@ async function validateInboundMedia(result) {
6621
6752
  }
6622
6753
  const buffer = Buffer.from(normalized, "base64");
6623
6754
  if (buffer.length === 0) return { ok: false, code: "MEDIA_EMPTY_DATA" };
6624
- const declared = MIME_BY_FORMAT[result.format.toLowerCase()] ?? `application/${result.format || "octet-stream"}`;
6755
+ const format = result.format.toLowerCase();
6756
+ const declared = MIME_BY_FORMAT[format] ?? "application/octet-stream";
6625
6757
  if (result.type === "image") {
6626
6758
  const detected = imageMimeFromMagic(buffer);
6627
6759
  if (!detected || detected !== declared) return { ok: false, code: "MEDIA_MAGIC_MISMATCH" };
6628
6760
  const invalidCode = await validateDecodedImage(buffer, detected);
6629
6761
  if (invalidCode) return { ok: false, code: invalidCode };
6762
+ } else if (result.type === "voice" || result.type === "video") {
6763
+ if (buffer.length > MAX_FILE_BYTES) return { ok: false, code: "MEDIA_FILE_TOO_LARGE" };
6764
+ const detected = binaryMimeFromMagic(buffer);
6765
+ if (!detected || detected !== declared) return { ok: false, code: "MEDIA_MAGIC_MISMATCH" };
6766
+ } else if (result.type === "file") {
6767
+ if (buffer.length > MAX_FILE_BYTES) return { ok: false, code: "MEDIA_FILE_TOO_LARGE" };
6768
+ if (!safeInboundFilename(result.filename)) return { ok: false, code: "MEDIA_FILENAME_INVALID" };
6769
+ if (!SUPPORTED_FILE_FORMATS.has(format)) return { ok: false, code: "MEDIA_FILE_TYPE_MISMATCH" };
6770
+ if (documentMimeFromMagic(buffer) !== "application/pdf") {
6771
+ return { ok: false, code: "MEDIA_FILE_TYPE_MISMATCH" };
6772
+ }
6630
6773
  }
6631
6774
  return { ok: true, value: { buffer, mime: declared } };
6632
6775
  }
6633
6776
  function safeBodyAfterKnownMediaFailure(baseType, content) {
6634
6777
  return baseType === 3 ? "[Image unavailable]" : content;
6635
6778
  }
6636
- async function saveValidatedInboundMedia(result, save) {
6637
- const validated = await validateInboundMedia(result);
6638
- if (!validated.ok) return validated;
6639
- try {
6640
- const saved = await save(validated.value.buffer, validated.value.mime, result.filename);
6641
- if (!saved?.path || typeof saved.path !== "string" || !saved.path.trim()) {
6642
- return { ok: false, code: "MEDIA_SAVE_FAILED" };
6643
- }
6644
- return { ok: true, path: saved.path, mime: validated.value.mime };
6645
- } catch {
6646
- return { ok: false, code: "MEDIA_SAVE_FAILED" };
6647
- }
6648
- }
6649
6779
 
6650
6780
  // src/inbound-media-poll.ts
6651
- async function pollMedia(client, chatId, localId, log, maxAttempts = 15, intervalMs = 1e3) {
6781
+ var RETRYABLE_MEDIA_ERRORS = /* @__PURE__ */ new Set([
6782
+ "MEDIA_NOT_DOWNLOADED",
6783
+ "IMAGE_RESOURCE_UNAVAILABLE",
6784
+ "FILE_NOT_DOWNLOADED",
6785
+ "FILE_NOT_STABLE",
6786
+ "VOICE_NOT_DOWNLOADED",
6787
+ "IMAGE_NOT_STABLE"
6788
+ ]);
6789
+ var DEFAULT_MEDIA_POLL_ATTEMPTS = 6;
6790
+ var DEFAULT_MEDIA_POLL_INTERVAL_MS = 500;
6791
+ var IMAGE_MATERIALIZATION_OPEN_CHAT_TIMEOUT_MS = 400;
6792
+ function createImageMaterializationTrigger(client, chatId, options) {
6793
+ const timeoutMs = options?.timeoutMs ?? IMAGE_MATERIALIZATION_OPEN_CHAT_TIMEOUT_MS;
6794
+ return async (result, attempt) => {
6795
+ if (result.type === "file") return;
6796
+ options?.log?.info?.(
6797
+ `[wechat:media] triggering chat reopen for image attempt=${attempt} skipOpen=${options?.skipOpen === true}`
6798
+ );
6799
+ const controller = new AbortController();
6800
+ const opened = Promise.resolve(client.openChat(chatId, true, controller.signal, timeoutMs));
6801
+ void opened.catch(() => void 0);
6802
+ try {
6803
+ await raceWithTimeout(opened, timeoutMs);
6804
+ } finally {
6805
+ controller.abort();
6806
+ }
6807
+ };
6808
+ }
6809
+ function imageMaterializationTriggerForMessage(opts) {
6810
+ const baseType = opts.messageType & 2147483647;
6811
+ if (baseType !== 3) return void 0;
6812
+ return createImageMaterializationTrigger(opts.client, opts.chatId, {
6813
+ log: opts.log,
6814
+ timeoutMs: opts.timeoutMs,
6815
+ skipOpen: opts.skipOpen
6816
+ });
6817
+ }
6818
+ function raceWithTimeout(promise, timeoutMs) {
6819
+ return new Promise((resolve, reject) => {
6820
+ const timer = setTimeout(() => {
6821
+ reject(Object.assign(new Error("IMAGE_MATERIALIZATION_OPEN_CHAT_TIMEOUT"), {
6822
+ code: "IMAGE_MATERIALIZATION_OPEN_CHAT_TIMEOUT"
6823
+ }));
6824
+ }, timeoutMs);
6825
+ promise.then(
6826
+ (value) => {
6827
+ clearTimeout(timer);
6828
+ resolve(value);
6829
+ },
6830
+ (error) => {
6831
+ clearTimeout(timer);
6832
+ reject(error);
6833
+ }
6834
+ );
6835
+ });
6836
+ }
6837
+ function isRetryable(result) {
6838
+ if (result.data !== void 0) return false;
6839
+ if (result.errorCode !== void 0) return RETRYABLE_MEDIA_ERRORS.has(result.errorCode);
6840
+ return result.type === "pending";
6841
+ }
6842
+ async function pollMedia(client, chatId, localId, log, maxAttempts = DEFAULT_MEDIA_POLL_ATTEMPTS, intervalMs = DEFAULT_MEDIA_POLL_INTERVAL_MS, onRetryTrigger) {
6843
+ let lastResult;
6844
+ let triggerUsed = false;
6845
+ const startedAt = Date.now();
6652
6846
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
6653
6847
  const result = await client.getMedia(chatId, localId);
6654
- if (result.type === "unsupported") return null;
6655
- if (result.data) return result;
6848
+ lastResult = result;
6849
+ if (result.type === "unsupported") return result;
6850
+ if (result.data || !isRetryable(result)) return result;
6851
+ if (!triggerUsed && onRetryTrigger) {
6852
+ triggerUsed = true;
6853
+ try {
6854
+ await onRetryTrigger(result, attempt);
6855
+ log?.info?.(`[wechat:media] download trigger completed attempt=${attempt}`);
6856
+ } catch {
6857
+ log?.info?.(`[wechat:media] download trigger failed attempt=${attempt} code=MEDIA_DOWNLOAD_TRIGGER_FAILED`);
6858
+ }
6859
+ }
6656
6860
  if (attempt < maxAttempts) {
6657
- log?.info?.(`[wechat:media] pending attempt=${attempt}/${maxAttempts}`);
6861
+ log?.info?.(`[wechat:media] pending attempt=${attempt}/${maxAttempts} elapsedMs=${Date.now() - startedAt}`);
6658
6862
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
6659
6863
  }
6660
6864
  }
6661
- return null;
6865
+ log?.info?.(
6866
+ `[wechat:media] retry exhausted attempts=${maxAttempts} elapsedMs=${Date.now() - startedAt} code=${lastResult?.errorCode ?? "MEDIA_NOT_DOWNLOADED"}`
6867
+ );
6868
+ return lastResult ?? null;
6869
+ }
6870
+
6871
+ // src/monitor-ledger.ts
6872
+ import { createHash } from "node:crypto";
6873
+ import {
6874
+ closeSync as closeSync3,
6875
+ existsSync as existsSync3,
6876
+ fsyncSync as fsyncSync3,
6877
+ openSync as openSync3
6878
+ } from "node:fs";
6879
+ import { homedir as homedir2 } from "node:os";
6880
+ import { dirname as dirname3, join as join2 } from "node:path";
6881
+
6882
+ // src/durable-store.ts
6883
+ import { randomUUID as randomUUID2 } from "node:crypto";
6884
+ import {
6885
+ closeSync as closeSync2,
6886
+ existsSync as existsSync2,
6887
+ fsyncSync as fsyncSync2,
6888
+ mkdirSync as mkdirSync2,
6889
+ openSync as openSync2,
6890
+ readFileSync as readFileSync2,
6891
+ renameSync as renameSync2,
6892
+ rmSync as rmSync2,
6893
+ statSync,
6894
+ writeFileSync as writeFileSync2
6895
+ } from "node:fs";
6896
+ import { dirname as dirname2, isAbsolute } from "node:path";
6897
+ var MAX_DURABLE_JSON_BYTES = 8 * 1024 * 1024;
6898
+ var LOCK_WAIT_MS = 5e3;
6899
+ var LOCK_STALE_MS = 3e4;
6900
+ var DurableStoreError = class extends Error {
6901
+ code = "DURABLE_STORE_IO_FAILED";
6902
+ constructor(message, options) {
6903
+ super(message, options);
6904
+ this.name = "DurableStoreError";
6905
+ }
6906
+ };
6907
+ function assertTrustedPath(path, root) {
6908
+ if (!isAbsolute(path) || path.includes("\0")) {
6909
+ throw new DurableStoreError("durable store paths must be absolute and contain no null bytes");
6910
+ }
6911
+ if (root) {
6912
+ if (!isAbsolute(root) || path !== root && !path.startsWith(`${root}/`)) {
6913
+ throw new DurableStoreError("durable store path is outside the approved state root");
6914
+ }
6915
+ for (const part of path.slice(root.length).split("/")) {
6916
+ if (!part) continue;
6917
+ const candidate = path.slice(0, path.indexOf(part) + part.length);
6918
+ try {
6919
+ if (statSync(candidate).isSymbolicLink()) throw new DurableStoreError("durable store path contains a symlink");
6920
+ } catch (error) {
6921
+ if (error instanceof DurableStoreError) throw error;
6922
+ }
6923
+ }
6924
+ }
6925
+ }
6926
+ function syncPath2(path) {
6927
+ const fd = openSync2(path, "r");
6928
+ try {
6929
+ fsyncSync2(fd);
6930
+ } finally {
6931
+ closeSync2(fd);
6932
+ }
6933
+ }
6934
+ function sleepSync(ms) {
6935
+ const buffer = new SharedArrayBuffer(4);
6936
+ Atomics.wait(new Int32Array(buffer), 0, 0, ms);
6937
+ }
6938
+ function lockPath(path) {
6939
+ return `${path}.lock`;
6940
+ }
6941
+ function acquireLock(path) {
6942
+ assertTrustedPath(path);
6943
+ mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
6944
+ const lock = lockPath(path);
6945
+ const deadline = Date.now() + LOCK_WAIT_MS;
6946
+ while (Date.now() < deadline) {
6947
+ try {
6948
+ writeFileSync2(lock, JSON.stringify({ version: 1, pid: process.pid, createdAt: Date.now() }), { flag: "wx", mode: 384 });
6949
+ syncPath2(lock);
6950
+ return lock;
6951
+ } catch (error) {
6952
+ if (error.code !== "EEXIST") {
6953
+ throw new DurableStoreError(`durable store lock failed: ${path}`, { cause: error });
6954
+ }
6955
+ try {
6956
+ if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) rmSync2(lock, { force: true });
6957
+ } catch {
6958
+ }
6959
+ sleepSync(10);
6960
+ }
6961
+ }
6962
+ throw new DurableStoreError(`durable store lock timeout: ${path}`);
6963
+ }
6964
+ function releaseLock(lock) {
6965
+ try {
6966
+ rmSync2(lock, { force: true });
6967
+ } catch {
6968
+ }
6969
+ }
6970
+ function withDurableJsonLock(path, operation) {
6971
+ const lock = acquireLock(path);
6972
+ try {
6973
+ return operation();
6974
+ } finally {
6975
+ releaseLock(lock);
6976
+ }
6977
+ }
6978
+ function serialize(value) {
6979
+ const json = JSON.stringify(value);
6980
+ if (Buffer.byteLength(json, "utf8") > MAX_DURABLE_JSON_BYTES) {
6981
+ throw new DurableStoreError(`durable store value exceeds ${MAX_DURABLE_JSON_BYTES} bytes`);
6982
+ }
6983
+ return json;
6984
+ }
6985
+ function writeDurableJsonLocked(path, value) {
6986
+ const temp = `${path}.tmp-${process.pid}-${randomUUID2()}`;
6987
+ try {
6988
+ writeFileSync2(temp, serialize(value), { mode: 384 });
6989
+ syncPath2(temp);
6990
+ renameSync2(temp, path);
6991
+ syncPath2(dirname2(path));
6992
+ } catch (error) {
6993
+ try {
6994
+ rmSync2(temp, { force: true });
6995
+ } catch {
6996
+ }
6997
+ throw error;
6998
+ }
6999
+ }
7000
+ function writeUnlocked(path, value) {
7001
+ writeDurableJsonLocked(path, value);
7002
+ }
7003
+ function updateDurableJson(path, update, root) {
7004
+ assertTrustedPath(path, root);
7005
+ try {
7006
+ return withDurableJsonLock(path, () => {
7007
+ const current = existsSync2(path) ? JSON.parse(readFileSync2(path, "utf8")) : void 0;
7008
+ const next = update(current);
7009
+ writeUnlocked(path, next);
7010
+ return next;
7011
+ });
7012
+ } catch (error) {
7013
+ if (error instanceof DurableStoreError) throw error;
7014
+ throw new DurableStoreError(`durable store update failed: ${path}`, { cause: error });
7015
+ }
7016
+ }
7017
+ function readDurableJson(path) {
7018
+ assertTrustedPath(path);
7019
+ if (!existsSync2(path)) return void 0;
7020
+ try {
7021
+ return JSON.parse(readFileSync2(path, "utf8"));
7022
+ } catch (error) {
7023
+ throw new DurableStoreError(`durable store read failed: ${path}`, { cause: error });
7024
+ }
7025
+ }
7026
+ function quarantineDurableJson(path, blockerPath3) {
7027
+ assertTrustedPath(path);
7028
+ assertTrustedPath(blockerPath3);
7029
+ const quarantinePath3 = `${path}.corrupt-${Date.now()}-${randomUUID2()}`;
7030
+ const markerPath = `${path}.quarantine`;
7031
+ try {
7032
+ withDurableJsonLock(path, () => {
7033
+ writeDurableJsonLocked(markerPath, { version: 1, quarantine: quarantinePath3, blocker: blockerPath3 });
7034
+ renameSync2(path, quarantinePath3);
7035
+ syncPath2(dirname2(path));
7036
+ writeDurableJsonLocked(blockerPath3, { version: 1, quarantine: quarantinePath3 });
7037
+ rmSync2(markerPath, { force: true });
7038
+ syncPath2(dirname2(path));
7039
+ });
7040
+ return quarantinePath3;
7041
+ } catch (error) {
7042
+ throw new DurableStoreError(`durable store quarantine failed: ${path}`, { cause: error });
7043
+ }
7044
+ }
7045
+ function recoverDurableQuarantine(path) {
7046
+ const markerPath = `${path}.quarantine`;
7047
+ if (!existsSync2(markerPath)) return;
7048
+ try {
7049
+ const marker = JSON.parse(readFileSync2(markerPath, "utf8"));
7050
+ if (typeof marker.quarantine !== "string" || typeof marker.blocker !== "string") throw new Error("invalid quarantine marker");
7051
+ withDurableJsonLock(path, () => {
7052
+ if (existsSync2(path)) renameSync2(path, marker.quarantine);
7053
+ writeDurableJsonLocked(marker.blocker, { version: 1, quarantine: marker.quarantine });
7054
+ rmSync2(markerPath, { force: true });
7055
+ syncPath2(dirname2(path));
7056
+ });
7057
+ } catch (error) {
7058
+ throw new DurableStoreError(`durable store quarantine recovery failed: ${path}`, { cause: error });
7059
+ }
7060
+ }
7061
+
7062
+ // src/monitor-ledger.ts
7063
+ var MAX_INBOUND_LEDGER_ENTRIES = 1e4;
7064
+ var MAX_INBOUND_EVENT_ATTEMPTS = 5;
7065
+ var MAX_INBOUND_RETRY_DELAY_MS = 6e4;
7066
+ var InboundLedgerStateError = class extends Error {
7067
+ code = "INBOUND_LEDGER_BLOCKED";
7068
+ constructor(message) {
7069
+ super(message);
7070
+ this.name = "InboundLedgerStateError";
7071
+ }
7072
+ };
7073
+ function statePath(accountId, stateDir) {
7074
+ const root = stateDir ?? process.env.OPENCLAW_STATE_DIR?.trim() ?? join2(homedir2(), ".openclaw");
7075
+ const safeAccount = accountId.replace(/[^A-Za-z0-9._-]/g, "_");
7076
+ return join2(root, "wechat", `inbound-ledger-${safeAccount}.json`);
7077
+ }
7078
+ function quarantinePath2(path) {
7079
+ return `${path}.corrupt`;
7080
+ }
7081
+ function blockerPath2(path) {
7082
+ return `${path}.blocked`;
7083
+ }
7084
+ function validRecord(value) {
7085
+ if (!value || typeof value !== "object") return false;
7086
+ const row = value;
7087
+ return typeof row.eventId === "string" && /^[a-f0-9]{64}$/.test(row.eventId) && typeof row.chatId === "string" && row.chatId.length > 0 && row.chatId.length <= 512 && Number.isSafeInteger(row.localId) && typeof row.messageKey === "string" && row.messageKey.length <= 4096 && ["pending", "processing", "processed", "failed", "dead_letter"].includes(String(row.status)) && Number.isSafeInteger(row.attempts) && Number(row.attempts) >= 0 && typeof row.createdAt === "number" && Number.isFinite(row.createdAt) && typeof row.updatedAt === "number" && Number.isFinite(row.updatedAt) && (row.nextRetryAt === void 0 || typeof row.nextRetryAt === "number" && Number.isFinite(row.nextRetryAt)) && (row.lastErrorCode === void 0 || typeof row.lastErrorCode === "string") && (row.outcome === void 0 || ["dispatched", "filtered", "buffered", "read_only", "stale"].includes(String(row.outcome)));
7088
+ }
7089
+ function validateFile(value) {
7090
+ if (!value || typeof value !== "object") throw new InboundLedgerStateError("inbound ledger is not an object");
7091
+ const row = value;
7092
+ if (row.version !== 1 || !Array.isArray(row.entries) || row.entries.length > MAX_INBOUND_LEDGER_ENTRIES) {
7093
+ throw new InboundLedgerStateError("inbound ledger schema or capacity is invalid");
7094
+ }
7095
+ const entries = row.entries;
7096
+ if (!entries.every(validRecord)) throw new InboundLedgerStateError("inbound ledger contains an invalid event");
7097
+ const ids = new Set(entries.map((entry) => entry.eventId));
7098
+ if (ids.size !== entries.length) throw new InboundLedgerStateError("inbound ledger contains duplicate events");
7099
+ return { version: 1, entries };
7100
+ }
7101
+ function loadFile(path) {
7102
+ if (existsSync3(blockerPath2(path))) {
7103
+ throw new InboundLedgerStateError(`inbound ledger is blocked: ${blockerPath2(path)}`);
7104
+ }
7105
+ if (!existsSync3(path)) return { version: 1, entries: [] };
7106
+ try {
7107
+ const parsed = readDurableJson(path);
7108
+ if (parsed === void 0) return { version: 1, entries: [] };
7109
+ return validateFile(parsed);
7110
+ } catch (error) {
7111
+ const quarantine = quarantinePath2(path);
7112
+ try {
7113
+ quarantineDurableJson(path, blockerPath2(path));
7114
+ } catch (quarantineError) {
7115
+ throw new InboundLedgerStateError(`inbound ledger quarantine failed: ${String(quarantineError)}`);
7116
+ }
7117
+ throw new InboundLedgerStateError(`inbound ledger quarantined at ${quarantine}: ${String(error)}`);
7118
+ }
7119
+ }
7120
+ function inboundEventId(accountId, chatId, message) {
7121
+ return createHash("sha256").update(`${accountId}\0${chatId}\0${cursorMessageKey(message)}`).digest("hex");
7122
+ }
7123
+ var InboundEventLedger = class {
7124
+ path;
7125
+ entries = /* @__PURE__ */ new Map();
7126
+ constructor(accountId, stateDir) {
7127
+ this.path = statePath(accountId, stateDir);
7128
+ recoverDurableQuarantine(this.path);
7129
+ for (const entry of loadFile(this.path).entries) this.entries.set(entry.eventId, entry);
7130
+ }
7131
+ persist() {
7132
+ const localEntries = [...this.entries.values()];
7133
+ const next = updateDurableJson(this.path, (current) => {
7134
+ const merged = new Map((current?.entries ?? []).map((entry) => [entry.eventId, entry]));
7135
+ for (const entry of localEntries) merged.set(entry.eventId, entry);
7136
+ const entries = [...merged.values()].sort((a, b) => a.updatedAt - b.updatedAt || a.eventId.localeCompare(b.eventId));
7137
+ if (entries.length > MAX_INBOUND_LEDGER_ENTRIES) {
7138
+ const retained = entries.filter((entry) => entry.status !== "processed");
7139
+ if (retained.length > MAX_INBOUND_LEDGER_ENTRIES) throw new InboundLedgerStateError("inbound ledger capacity exceeded");
7140
+ return { version: 1, entries: retained };
7141
+ }
7142
+ return { version: 1, entries };
7143
+ });
7144
+ this.entries.clear();
7145
+ for (const entry of next.entries) this.entries.set(entry.eventId, entry);
7146
+ }
7147
+ get(eventId) {
7148
+ const entry = this.entries.get(eventId);
7149
+ return entry ? { ...entry } : void 0;
7150
+ }
7151
+ ensure(accountId, chatId, message, now = Date.now()) {
7152
+ const eventId = inboundEventId(accountId, chatId, message);
7153
+ if (!this.entries.has(eventId)) {
7154
+ this.entries.set(eventId, {
7155
+ eventId,
7156
+ chatId,
7157
+ localId: message.localId,
7158
+ messageKey: cursorMessageKey(message),
7159
+ status: "pending",
7160
+ attempts: 0,
7161
+ createdAt: now,
7162
+ updatedAt: now
7163
+ });
7164
+ this.persist();
7165
+ }
7166
+ return eventId;
7167
+ }
7168
+ shouldProcess(eventId, now = Date.now()) {
7169
+ const entry = this.entries.get(eventId);
7170
+ if (!entry || entry.status === "processed" || entry.status === "dead_letter") return false;
7171
+ return entry.status !== "failed" || (entry.nextRetryAt ?? 0) <= now;
7172
+ }
7173
+ markProcessing(eventId, now = Date.now()) {
7174
+ const entry = this.entries.get(eventId);
7175
+ if (!entry || !this.shouldProcess(eventId, now)) return;
7176
+ entry.status = "processing";
7177
+ entry.attempts += 1;
7178
+ entry.updatedAt = now;
7179
+ entry.nextRetryAt = void 0;
7180
+ this.persist();
7181
+ }
7182
+ markProcessed(eventId, outcome, now = Date.now()) {
7183
+ const entry = this.entries.get(eventId);
7184
+ if (!entry) return;
7185
+ entry.status = "processed";
7186
+ entry.outcome = outcome;
7187
+ entry.updatedAt = now;
7188
+ entry.nextRetryAt = void 0;
7189
+ entry.lastErrorCode = void 0;
7190
+ this.persist();
7191
+ }
7192
+ markFailed(eventId, errorCode, now = Date.now()) {
7193
+ const entry = this.entries.get(eventId);
7194
+ if (!entry) return;
7195
+ entry.updatedAt = now;
7196
+ entry.lastErrorCode = errorCode.slice(0, 128);
7197
+ if (entry.attempts >= MAX_INBOUND_EVENT_ATTEMPTS) {
7198
+ entry.status = "dead_letter";
7199
+ entry.nextRetryAt = void 0;
7200
+ } else {
7201
+ entry.status = "failed";
7202
+ entry.nextRetryAt = now + Math.min(MAX_INBOUND_RETRY_DELAY_MS, 1e3 * 2 ** Math.min(entry.attempts - 1, 6));
7203
+ }
7204
+ this.persist();
7205
+ }
7206
+ list(filter) {
7207
+ return [...this.entries.values()].filter((entry) => (!filter?.chatId || entry.chatId === filter.chatId) && (!filter?.status || entry.status === filter.status) && (!filter?.since || entry.updatedAt >= filter.since)).sort((a, b) => b.updatedAt - a.updatedAt).map((entry) => ({ ...entry }));
7208
+ }
7209
+ markProcessedBatch(eventIds, outcome, now = Date.now()) {
7210
+ for (const eventId of eventIds) this.markProcessed(eventId, outcome, now);
7211
+ }
7212
+ markFailedBatch(eventIds, errorCode, now = Date.now()) {
7213
+ for (const eventId of eventIds) this.markFailed(eventId, errorCode, now);
7214
+ }
7215
+ replay(eventId, now = Date.now()) {
7216
+ const entry = this.entries.get(eventId);
7217
+ if (!entry || entry.status === "processed") return false;
7218
+ entry.status = "pending";
7219
+ entry.updatedAt = now;
7220
+ entry.nextRetryAt = now;
7221
+ entry.lastErrorCode = void 0;
7222
+ this.persist();
7223
+ return true;
7224
+ }
7225
+ };
7226
+ function loadInboundEventLedger(accountId, stateDir) {
7227
+ return new InboundEventLedger(accountId, stateDir);
7228
+ }
7229
+
7230
+ // src/media-pipeline.ts
7231
+ import { createHash as createHash2 } from "node:crypto";
7232
+ import { closeSync as closeSync4, existsSync as existsSync4, fsyncSync as fsyncSync4, mkdirSync as mkdirSync4, openSync as openSync4, readFileSync as readFileSync4, renameSync as renameSync4, writeFileSync as writeFileSync4 } from "node:fs";
7233
+ import { homedir as homedir3 } from "node:os";
7234
+ import { dirname as dirname4, join as join3 } from "node:path";
7235
+ import sharp2 from "sharp";
7236
+ var MAX_MEDIA_PIPELINE_ENTRIES = 5e3;
7237
+ var MEDIA_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
7238
+ function pipelinePath(accountId, stateDir) {
7239
+ const root = stateDir ?? process.env.OPENCLAW_STATE_DIR?.trim() ?? join3(homedir3(), ".openclaw");
7240
+ const safeAccount = accountId.replace(/[^A-Za-z0-9._-]/g, "_");
7241
+ return join3(root, "wechat", `media-pipeline-${safeAccount}.json`);
7242
+ }
7243
+ function syncFile(path) {
7244
+ const fd = openSync4(path, "r");
7245
+ try {
7246
+ fsyncSync4(fd);
7247
+ } finally {
7248
+ closeSync4(fd);
7249
+ }
7250
+ }
7251
+ function syncParent2(path) {
7252
+ syncFile(dirname4(path));
7253
+ }
7254
+ function writeState(path, file) {
7255
+ mkdirSync4(dirname4(path), { recursive: true, mode: 448 });
7256
+ const temp = `${path}.tmp-${process.pid}`;
7257
+ writeFileSync4(temp, JSON.stringify(file), { mode: 384 });
7258
+ syncFile(temp);
7259
+ renameSync4(temp, path);
7260
+ syncParent2(path);
7261
+ }
7262
+ function validRecord2(value) {
7263
+ if (!value || typeof value !== "object") return false;
7264
+ const row = value;
7265
+ return typeof row.hash === "string" && /^[a-f0-9]{64}$/.test(row.hash) && (row.status === "processed" || row.status === "failed") && typeof row.type === "string" && typeof row.mime === "string" && Array.isArray(row.stages) && row.stages.every((stage) => ["validated", "deduplicated", "original_saved", "preview_generated", "preview_failed", "extraction_skipped"].includes(String(stage))) && Array.isArray(row.bindings) && row.bindings.length <= 1e3 && row.bindings.every((binding) => {
7266
+ if (!binding || typeof binding !== "object") return false;
7267
+ const item = binding;
7268
+ return typeof item.eventId === "string" && /^[a-f0-9]{64}$/.test(item.eventId) && typeof item.chatId === "string" && item.chatId.length > 0 && item.chatId.length <= 512 && Number.isSafeInteger(item.localId) && typeof item.observedAt === "number" && Number.isFinite(item.observedAt);
7269
+ }) && (row.extraction === "not_configured" || row.extraction === "not_applicable") && typeof row.createdAt === "number" && Number.isFinite(row.createdAt) && typeof row.updatedAt === "number" && Number.isFinite(row.updatedAt);
7270
+ }
7271
+ function readState(path) {
7272
+ if (!existsSync4(path)) return { version: 1, entries: [] };
7273
+ try {
7274
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
7275
+ if (parsed.version !== 1 || !Array.isArray(parsed.entries) || parsed.entries.length > MAX_MEDIA_PIPELINE_ENTRIES || !parsed.entries.every(validRecord2)) throw new Error("invalid media pipeline state");
7276
+ return { version: 1, entries: parsed.entries };
7277
+ } catch (error) {
7278
+ throw new Error(`media pipeline state is invalid: ${String(error)}`);
7279
+ }
7280
+ }
7281
+ function safeFilename(raw) {
7282
+ const base = raw.replace(/\\/g, "/").split("/").pop() ?? "attachment";
7283
+ const cleaned = [...base].filter((char) => char >= " " && char !== "\x7F").join("").trim();
7284
+ return (cleaned || "attachment").slice(0, 180);
7285
+ }
7286
+ function previewFilename(filename) {
7287
+ const base = safeFilename(filename).replace(/\.[^.]+$/, "");
7288
+ return `${base || "attachment"}.preview.jpg`;
7289
+ }
7290
+ var MediaPipeline = class {
7291
+ path;
7292
+ entries = /* @__PURE__ */ new Map();
7293
+ constructor(accountId, stateDir) {
7294
+ this.path = pipelinePath(accountId, stateDir);
7295
+ for (const entry of readState(this.path).entries) this.entries.set(entry.hash, entry);
7296
+ }
7297
+ persist() {
7298
+ const entries = [...this.entries.values()].sort((a, b) => b.updatedAt - a.updatedAt);
7299
+ if (entries.length > MAX_MEDIA_PIPELINE_ENTRIES) entries.splice(MAX_MEDIA_PIPELINE_ENTRIES);
7300
+ this.entries.clear();
7301
+ for (const entry of entries) this.entries.set(entry.hash, entry);
7302
+ writeState(this.path, { version: 1, entries });
7303
+ }
7304
+ async removePath(removeMedia, path) {
7305
+ if (!removeMedia || !path) return false;
7306
+ try {
7307
+ await removeMedia(path);
7308
+ return true;
7309
+ } catch {
7310
+ return false;
7311
+ }
7312
+ }
7313
+ copy(row) {
7314
+ return { ...row, stages: [...row.stages], bindings: row.bindings.map((binding) => ({ ...binding })) };
7315
+ }
7316
+ get(hash) {
7317
+ const row = this.entries.get(hash);
7318
+ return row ? this.copy(row) : void 0;
7319
+ }
7320
+ getByEventId(eventId) {
7321
+ const row = [...this.entries.values()].find((entry) => entry.bindings.some((binding) => binding.eventId === eventId));
7322
+ return row ? this.copy(row) : void 0;
7323
+ }
7324
+ list() {
7325
+ return [...this.entries.values()].map((row) => this.copy(row));
7326
+ }
7327
+ async cleanup(options) {
7328
+ const cutoff = (options.now ?? Date.now()) - Math.max(0, options.olderThanMs);
7329
+ const expired = [...this.entries.values()].filter((entry) => entry.updatedAt < cutoff && (entry.originalPath !== void 0 || entry.previewPath !== void 0));
7330
+ let changed = 0;
7331
+ for (const entry of expired) {
7332
+ const originalRemoved = await this.removePath(options.remove, entry.originalPath);
7333
+ const previewRemoved = await this.removePath(options.remove, entry.previewPath);
7334
+ if (originalRemoved) entry.originalPath = void 0;
7335
+ if (previewRemoved) entry.previewPath = void 0;
7336
+ if (originalRemoved || previewRemoved) {
7337
+ entry.updatedAt = options.now ?? Date.now();
7338
+ changed += 1;
7339
+ }
7340
+ }
7341
+ if (changed > 0) this.persist();
7342
+ return changed;
7343
+ }
7344
+ async process(result, binding, saveOriginal, savePreview, removeMedia, now = Date.now()) {
7345
+ const validated = await validateInboundMedia(result);
7346
+ if (!validated.ok) return validated;
7347
+ const { buffer, mime } = validated.value;
7348
+ const hash = createHash2("sha256").update(buffer).digest("hex");
7349
+ const existing = this.entries.get(hash);
7350
+ const observedBinding = { ...binding, observedAt: now };
7351
+ if (existing?.status === "processed" && existing.originalPath) {
7352
+ if (!existing.bindings.some((item) => item.eventId === binding.eventId)) {
7353
+ existing.bindings.push(observedBinding);
7354
+ if (existing.bindings.length > 1e3) existing.bindings.shift();
7355
+ existing.updatedAt = now;
7356
+ this.persist();
7357
+ }
7358
+ return { ok: true, hash, mime: existing.mime, originalPath: existing.originalPath, previewPath: existing.previewPath, record: this.copy(existing) };
7359
+ }
7360
+ const record = {
7361
+ hash,
7362
+ status: "failed",
7363
+ type: result.type,
7364
+ mime,
7365
+ stages: ["validated", "deduplicated"],
7366
+ extraction: result.type === "image" || result.type === "file" ? "not_configured" : "not_applicable",
7367
+ bindings: [...(existing?.bindings ?? []).filter((item) => item.eventId !== binding.eventId), observedBinding].slice(-1e3),
7368
+ createdAt: existing?.createdAt ?? now,
7369
+ updatedAt: now
7370
+ };
7371
+ const original = await saveOriginal(buffer, mime, safeFilename(result.filename));
7372
+ if (!original?.path?.trim()) {
7373
+ record.errorCode = "MEDIA_SAVE_FAILED";
7374
+ this.entries.set(hash, record);
7375
+ this.persist();
7376
+ return { ok: false, code: "MEDIA_SAVE_FAILED" };
7377
+ }
7378
+ record.originalPath = original.path;
7379
+ record.stages.push("original_saved");
7380
+ if (result.type === "image") {
7381
+ if (!savePreview) {
7382
+ if (await this.removePath(removeMedia, record.originalPath)) record.originalPath = void 0;
7383
+ record.errorCode = "MEDIA_PREVIEW_FAILED";
7384
+ record.stages.push("preview_failed");
7385
+ this.entries.set(hash, record);
7386
+ this.persist();
7387
+ return { ok: false, code: "MEDIA_PREVIEW_FAILED" };
7388
+ }
7389
+ try {
7390
+ const preview = await sharp2(buffer, { failOn: "warning", limitInputPixels: 4e7 }).rotate().resize({ width: 1024, height: 1024, fit: "inside", withoutEnlargement: true }).jpeg({ quality: 82, progressive: true }).toBuffer();
7391
+ const savedPreview = await savePreview(preview, "image/jpeg", previewFilename(result.filename));
7392
+ if (!savedPreview?.path?.trim()) throw new Error("preview save returned no path");
7393
+ record.previewPath = savedPreview.path;
7394
+ record.stages.push("preview_generated");
7395
+ } catch {
7396
+ if (await this.removePath(removeMedia, record.originalPath)) record.originalPath = void 0;
7397
+ if (await this.removePath(removeMedia, record.previewPath)) record.previewPath = void 0;
7398
+ record.errorCode = "MEDIA_PREVIEW_FAILED";
7399
+ record.stages.push("preview_failed");
7400
+ this.entries.set(hash, record);
7401
+ this.persist();
7402
+ return { ok: false, code: "MEDIA_PREVIEW_FAILED" };
7403
+ }
7404
+ }
7405
+ record.stages.push("extraction_skipped");
7406
+ record.status = "processed";
7407
+ record.updatedAt = now;
7408
+ this.entries.set(hash, record);
7409
+ this.persist();
7410
+ return { ok: true, hash, mime, originalPath: original.path, previewPath: record.previewPath, record: this.copy(record) };
7411
+ }
7412
+ };
7413
+ function loadMediaPipeline(accountId, stateDir) {
7414
+ return new MediaPipeline(accountId, stateDir);
7415
+ }
7416
+
7417
+ // src/newsapp.ts
7418
+ var NEWSAPP_USERNAME = "newsapp";
7419
+ var MAX_NEWSAPP_PREVIEW_CHARS = 4e3;
7420
+ function isNewsappChat(chat) {
7421
+ return (chat.username || chat.id) === NEWSAPP_USERNAME;
7422
+ }
7423
+ var URL_RE = /https?:\/\/[^\s<>,。;!?)】》]+/gi;
7424
+ function extractNewsappUrls(text) {
7425
+ return [...new Set(text.match(URL_RE) ?? [])].map(
7426
+ (url) => url.replace(/[),.;!?,。;!?)】》]+$/u, "")
7427
+ );
7428
+ }
7429
+ function newsappFallbackMessage(chat) {
7430
+ const rawPreview = chat.lastMessagePreview?.trim();
7431
+ const preview = rawPreview?.slice(0, MAX_NEWSAPP_PREVIEW_CHARS);
7432
+ const localId = chat.lastMsgLocalId;
7433
+ if (!preview || typeof localId !== "number" || !Number.isSafeInteger(localId) || localId <= 0) return null;
7434
+ const urls = extractNewsappUrls(preview);
7435
+ const urlBlock = urls.length > 0 ? `
7436
+ Links:
7437
+ ${urls.join("\n")}` : "";
7438
+ return {
7439
+ localId,
7440
+ serverId: localId,
7441
+ chatId: chat.username || chat.id,
7442
+ type: 1,
7443
+ content: `[Tencent News]
7444
+ ${preview}${urlBlock}`,
7445
+ timestamp: chat.lastActivityAt || `1970-01-01T00:00:${String(localId % 60).padStart(2, "0")}.000Z`,
7446
+ sender: NEWSAPP_USERNAME,
7447
+ senderName: "\u817E\u8BAF\u65B0\u95FB"
7448
+ };
7449
+ }
7450
+ function shouldBypassNewsappAuthorization(chat) {
7451
+ return isNewsappChat(chat);
7452
+ }
7453
+
7454
+ // src/monitor-skip.ts
7455
+ var SYSTEM_SERVICE_USERNAMES = /* @__PURE__ */ new Set([
7456
+ "qmessage",
7457
+ "floatbottle",
7458
+ "medianote",
7459
+ "notifymessage",
7460
+ "weixin",
7461
+ "fmessage",
7462
+ "filehelper",
7463
+ "newsapp",
7464
+ "tmessage",
7465
+ "mphelper",
7466
+ "qqmail",
7467
+ "weixingongzhong",
7468
+ "qqsafe",
7469
+ "exmail_tool",
7470
+ "lbsapp",
7471
+ "pc_qq"
7472
+ ]);
7473
+ var EMPTY_UNREAD_BACKOFF_MS = 5e3;
7474
+ var EMPTY_UNREAD_BACKOFF_MAX_MS = 6e4;
7475
+ function isOfficialAccount(chatId) {
7476
+ return chatId.startsWith("gh_") || SYSTEM_SERVICE_USERNAMES.has(chatId);
7477
+ }
7478
+ function isEmptyUnreadBackoffActive(backoff, chatId, now = Date.now()) {
7479
+ const entry = backoff.get(chatId);
7480
+ return entry !== void 0 && now < entry.nextRetryAt;
7481
+ }
7482
+ function applyEmptyUnreadSkip(chatId, opts) {
7483
+ if (opts.unreadCount <= 0) return null;
7484
+ const now = opts.now ?? Date.now();
7485
+ const seededLastSeen = opts.firstPoll && !opts.lastSeenId.has(chatId);
7486
+ if (seededLastSeen) {
7487
+ opts.lastSeenId.set(chatId, opts.prevLastSeen);
7488
+ }
7489
+ const retryCount = (opts.backoff.get(chatId)?.retryCount ?? 0) + 1;
7490
+ const backoffMs = Math.min(
7491
+ EMPTY_UNREAD_BACKOFF_MAX_MS,
7492
+ EMPTY_UNREAD_BACKOFF_MS * 2 ** Math.min(retryCount - 1, 6)
7493
+ );
7494
+ opts.backoff.set(chatId, { nextRetryAt: now + backoffMs, retryCount });
7495
+ return { seededLastSeen, backoffMs };
6662
7496
  }
6663
7497
 
6664
7498
  // src/monitor.ts
6665
7499
  var MEDIA_TYPES = /* @__PURE__ */ new Set([3, 34, 43]);
6666
7500
  var HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]";
6667
7501
  var CURRENT_MESSAGE_MARKER = "[Current message - respond to this]";
6668
- function isOfficialAccount(chatId) {
6669
- return chatId.startsWith("gh_");
6670
- }
6671
7502
  function sleep(ms, signal) {
6672
7503
  return new Promise((resolve) => {
6673
7504
  const timer = setTimeout(resolve, ms);
@@ -6695,6 +7526,16 @@ async function startWeChatMonitor(opts) {
6695
7526
  const startupBaselines = /* @__PURE__ */ new Map();
6696
7527
  const chatScanState = { initialScanComplete: false };
6697
7528
  const pendingMessageScans = loadPendingResetRetries(account.accountId);
7529
+ const inboundLedger = loadInboundEventLedger(account.accountId);
7530
+ const mediaPipeline = loadMediaPipeline(account.accountId);
7531
+ const emptyUnreadBackoff = /* @__PURE__ */ new Map();
7532
+ const cleanupMedia = async () => {
7533
+ await mediaPipeline.cleanup({
7534
+ olderThanMs: MEDIA_RETENTION_MS,
7535
+ remove: async (path) => unlink(path)
7536
+ }).catch((err) => log?.error?.(`[wechat:${account.accountId}] Media cleanup failed: ${err}`));
7537
+ };
7538
+ let lastMediaCleanup = 0;
6698
7539
  for (const [chatId, pending] of pendingMessageScans) {
6699
7540
  const cursor = pending.messages.reduce(
6700
7541
  (max, message) => Math.max(max, message.localId),
@@ -6708,6 +7549,7 @@ async function startWeChatMonitor(opts) {
6708
7549
  persist: () => persistPendingResetRetries(account.accountId, pendingMessageScans)
6709
7550
  };
6710
7551
  trimPendingResetRetries(retryState);
7552
+ const newsappHandled = /* @__PURE__ */ new Map();
6711
7553
  const groupHistory = /* @__PURE__ */ new Map();
6712
7554
  const GROUP_HISTORY_LIMIT = 50;
6713
7555
  let lastAuthCheck = 0;
@@ -6724,6 +7566,10 @@ async function startWeChatMonitor(opts) {
6724
7566
  try {
6725
7567
  const cfg = getWeChatRuntime().config.current();
6726
7568
  const now = Date.now();
7569
+ if (now - lastMediaCleanup >= MEDIA_RETENTION_MS / 4) {
7570
+ lastMediaCleanup = now;
7571
+ await cleanupMedia();
7572
+ }
6727
7573
  if (now - lastAuthCheck >= account.authPollIntervalMs) {
6728
7574
  lastAuthCheck = now;
6729
7575
  try {
@@ -6801,18 +7647,29 @@ async function startWeChatMonitor(opts) {
6801
7647
  }
6802
7648
  }
6803
7649
  }
6804
- const unreadChats = chats.filter(
6805
- (c) => c.unreadCount > 0 && !isOfficialAccount(c.username ?? c.id)
6806
- );
7650
+ const unreadChats = chats.filter((c) => {
7651
+ const chatId = c.username ?? c.id;
7652
+ if (isEmptyUnreadBackoffActive(emptyUnreadBackoff, chatId)) return false;
7653
+ if (c.unreadCount <= 0) return false;
7654
+ if (isNewsappChat(c)) return true;
7655
+ return !isOfficialAccount(chatId);
7656
+ });
6807
7657
  for (const chat of unreadChats) {
6808
7658
  const chatId = chat.username ?? chat.id;
6809
7659
  if (chatScanState.initialScanComplete && !lastSeenId.has(chatId) && !startupBaselines.has(chatId)) {
6810
7660
  startupBaselines.set(chatId, { localId: 0 });
6811
7661
  }
6812
7662
  }
6813
- const chatsToProcess = monitorChatsToProcess(chats, retryState);
7663
+ const chatsToProcess = monitorChatsToProcess(
7664
+ chats,
7665
+ retryState,
7666
+ Date.now(),
7667
+ (chat) => !isNewsappChat(chat) || newsappHandled.get(chat.username ?? chat.id) !== `${chat.lastMsgLocalId ?? ""}:${chat.lastMessagePreview?.trim() ?? ""}`
7668
+ );
6814
7669
  for (const chatId of chatsToProcess.keys()) {
6815
- if (isOfficialAccount(chatId)) chatsToProcess.delete(chatId);
7670
+ if (isOfficialAccount(chatId) && chatId !== "newsapp" || isEmptyUnreadBackoffActive(emptyUnreadBackoff, chatId)) {
7671
+ chatsToProcess.delete(chatId);
7672
+ }
6816
7673
  }
6817
7674
  if (unreadChats.length > 0) {
6818
7675
  log?.info?.(
@@ -6832,6 +7689,9 @@ async function startWeChatMonitor(opts) {
6832
7689
  startupBaselines,
6833
7690
  pendingMessageScans,
6834
7691
  retryState.persist,
7692
+ inboundLedger,
7693
+ mediaPipeline,
7694
+ emptyUnreadBackoff,
6835
7695
  account,
6836
7696
  cfg,
6837
7697
  log,
@@ -6851,12 +7711,18 @@ async function startWeChatMonitor(opts) {
6851
7711
  if (result === "deferred") {
6852
7712
  deferredThisTick += 1;
6853
7713
  }
7714
+ if (isNewsappChat(chat) && result !== "deferred") {
7715
+ newsappHandled.set(
7716
+ chat.username ?? chat.id,
7717
+ `${chat.lastMsgLocalId ?? ""}:${chat.lastMessagePreview?.trim() ?? ""}`
7718
+ );
7719
+ }
6854
7720
  }
6855
7721
  }
6856
7722
  for (const chat of chats) {
6857
7723
  if (abortSignal.aborted) break;
6858
7724
  const chatId = chat.username ?? chat.id;
6859
- if (isOfficialAccount(chatId)) continue;
7725
+ if (isOfficialAccount(chatId) && chatId !== "newsapp" || isEmptyUnreadBackoffActive(emptyUnreadBackoff, chatId)) continue;
6860
7726
  const prevSeen = lastSeenId.get(chatId);
6861
7727
  if (prevSeen === void 0) continue;
6862
7728
  if (unreadChats.some((c) => (c.username ?? c.id) === chatId)) continue;
@@ -6872,6 +7738,9 @@ async function startWeChatMonitor(opts) {
6872
7738
  startupBaselines,
6873
7739
  pendingMessageScans,
6874
7740
  retryState.persist,
7741
+ inboundLedger,
7742
+ mediaPipeline,
7743
+ emptyUnreadBackoff,
6875
7744
  account,
6876
7745
  cfg,
6877
7746
  log,
@@ -6909,13 +7778,14 @@ async function startWeChatMonitor(opts) {
6909
7778
  }
6910
7779
  await sleep(account.pollIntervalMs, abortSignal);
6911
7780
  }
7781
+ await cleanupMedia();
6912
7782
  setStatus({
6913
7783
  accountId: account.accountId,
6914
7784
  running: false,
6915
7785
  connected: false
6916
7786
  });
6917
7787
  }
6918
- async function prepareMessage(client, msg, chatId, chat, liveAccount, policy, log) {
7788
+ async function prepareMessage(client, mediaPipeline, msg, chatId, chat, liveAccount, policy, log, skipOpen) {
6919
7789
  const core = getWeChatRuntime();
6920
7790
  if (msg.isSelf) {
6921
7791
  log?.info?.(`[wechat:${liveAccount.accountId}] Skipping self-sent msg ${msg.localId}`);
@@ -6925,7 +7795,7 @@ async function prepareMessage(client, msg, chatId, chat, liveAccount, policy, lo
6925
7795
  const senderId = msg.sender ?? chatId;
6926
7796
  const senderName = msg.senderName ?? msg.sender ?? chat.name;
6927
7797
  const wasMentioned = isGroup && msg.isMentioned === true;
6928
- const access = resolveWeChatInboundAccessDecision({
7798
+ const access = shouldBypassNewsappAuthorization(chat) ? { allowed: true, reason: "newsapp-system-feed" } : resolveWeChatInboundAccessDecision({
6929
7799
  isGroup,
6930
7800
  senderId,
6931
7801
  policy
@@ -6938,32 +7808,60 @@ async function prepareMessage(client, msg, chatId, chat, liveAccount, policy, lo
6938
7808
  }
6939
7809
  let mediaPath;
6940
7810
  let mediaMime;
7811
+ let mediaHash;
7812
+ let mediaPreviewPath;
7813
+ let mediaSource;
7814
+ let mediaErrorCode;
6941
7815
  let hasMedia = false;
6942
7816
  const baseType = msg.type & 2147483647;
6943
7817
  const mayHaveMedia = MEDIA_TYPES.has(baseType) || baseType === 49;
6944
7818
  if (mayHaveMedia) {
6945
7819
  log?.info?.(`[wechat:${liveAccount.accountId}] Checking media for msg ${msg.localId} (type ${baseType})`);
6946
7820
  try {
6947
- const result = await pollMedia(client, chatId, msg.localId, log);
7821
+ const result = await pollMedia(
7822
+ client,
7823
+ chatId,
7824
+ msg.localId,
7825
+ log,
7826
+ void 0,
7827
+ void 0,
7828
+ imageMaterializationTriggerForMessage({
7829
+ client,
7830
+ chatId,
7831
+ messageType: baseType,
7832
+ log,
7833
+ skipOpen
7834
+ })
7835
+ );
6948
7836
  if (result && result.data && result.type !== "unsupported") {
6949
7837
  hasMedia = true;
7838
+ mediaSource = result.source;
6950
7839
  log?.info?.(`[wechat:media] inbound media received type=${result.type} format=${result.format}`);
6951
- const saved = await saveValidatedInboundMedia(
7840
+ const saved = await mediaPipeline.process(
6952
7841
  result,
6953
- async (buffer, mime, filename) => core.channel.media.saveMediaBuffer(buffer, mime, "inbound", void 0, filename)
7842
+ { eventId: inboundEventId(liveAccount.accountId, chatId, msg), chatId, localId: msg.localId },
7843
+ async (buffer, mime, filename) => core.channel.media.saveMediaBuffer(buffer, mime, "inbound", void 0, filename),
7844
+ async (buffer, mime, filename) => core.channel.media.saveMediaBuffer(buffer, mime, "inbound", void 0, filename),
7845
+ async (path) => unlink(path)
6954
7846
  );
6955
7847
  if (!saved.ok) {
7848
+ mediaErrorCode = saved.code;
6956
7849
  log?.error?.(`[wechat:media] inbound media rejected code=${saved.code}`);
6957
7850
  } else {
6958
7851
  mediaMime = saved.mime;
6959
- mediaPath = saved.path;
6960
- log?.info?.("[wechat:media] inbound media saved");
7852
+ mediaPath = saved.originalPath;
7853
+ mediaHash = saved.hash;
7854
+ mediaPreviewPath = saved.previewPath;
7855
+ log?.info?.(`[wechat:media] inbound media saved hash=${saved.hash}${saved.previewPath ? " preview=present" : ""}`);
6961
7856
  }
6962
- } else if (MEDIA_TYPES.has(baseType)) {
7857
+ } else if (result?.errorCode || result && result.type !== "unsupported" || MEDIA_TYPES.has(baseType)) {
6963
7858
  hasMedia = true;
6964
- log?.info?.("[wechat:media] inbound media unavailable after retries");
7859
+ mediaErrorCode = result?.errorCode ?? "MEDIA_DOWNLOAD_UNAVAILABLE";
7860
+ log?.info?.(`[wechat:media] inbound media unavailable code=${mediaErrorCode}`);
6965
7861
  }
6966
7862
  } catch (err) {
7863
+ hasMedia = true;
7864
+ mediaErrorCode = "MEDIA_DOWNLOAD_FAILED";
6967
7865
  log?.error?.("[wechat:media] inbound media failed code=MEDIA_DOWNLOAD_FAILED");
6968
7866
  }
6969
7867
  }
@@ -6981,12 +7879,22 @@ async function prepareMessage(client, msg, chatId, chat, liveAccount, policy, lo
6981
7879
  } else if (!mediaMime.startsWith("image/") && !mediaMime.startsWith("audio/")) {
6982
7880
  rawBody = `[File: ${rawBody}]`;
6983
7881
  }
7882
+ } else if (mediaErrorCode && baseType === 49) {
7883
+ const text = msg.content || "File attachment";
7884
+ rawBody = `${text}
7885
+ [Attachment unavailable: ${mediaErrorCode}]`;
7886
+ }
7887
+ if (mediaSource === "thumbnail") {
7888
+ rawBody = rawBody ? `${rawBody}
7889
+ [Image source: thumbnail]` : "[Image source: thumbnail]";
6984
7890
  }
6985
7891
  if (msg.reply) {
6986
7892
  const replySender = msg.reply.sender ?? "unknown sender";
6987
7893
  const quotedBody = msg.reply.content.length > 50 ? msg.reply.content.slice(0, 50) + "..." : msg.reply.content;
7894
+ const replyMedia = msg.reply.mediaErrorCode ? `
7895
+ [Quoted attachment unavailable: ${msg.reply.mediaErrorCode}]` : "";
6988
7896
  const replyBlock = `[Replying to ${replySender}]
6989
- ${quotedBody}
7897
+ ${quotedBody}${replyMedia}
6990
7898
  [/Replying]`;
6991
7899
  rawBody = rawBody ? `${rawBody}
6992
7900
 
@@ -7001,6 +7909,8 @@ ${replyBlock}` : replyBlock;
7001
7909
  }),
7002
7910
  mediaPath,
7003
7911
  mediaMime,
7912
+ mediaHash,
7913
+ mediaPreviewPath,
7004
7914
  senderName,
7005
7915
  senderId,
7006
7916
  isGroup,
@@ -7163,7 +8073,19 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
7163
8073
  CommandAuthorized: commandAuthorized,
7164
8074
  OriginatingChannel: OPENCLAW_CHANNEL_ID,
7165
8075
  OriginatingTo: `${OPENCLAW_CHANNEL_ID}:${chatId}`,
7166
- ...mediaPath ? { MediaPath: mediaPath, MediaUrl: mediaPath, MediaType: mediaMime } : {},
8076
+ ...mediaPath ? {
8077
+ // The runtime media consumers read the plural attachment contract. Keep
8078
+ // the legacy singular aliases for older runtimes, but always populate
8079
+ // both forms for downloaded inbound media.
8080
+ MediaPath: mediaPath,
8081
+ MediaUrl: mediaPath,
8082
+ MediaType: mediaMime,
8083
+ MediaPaths: [mediaPath],
8084
+ MediaUrls: [mediaPath],
8085
+ MediaTypes: mediaMime ? [mediaMime] : [],
8086
+ ...mediaMsg?.mediaHash ? { MediaHash: mediaMsg.mediaHash } : {},
8087
+ ...mediaMsg?.mediaPreviewPath ? { MediaPreviewPath: mediaMsg.mediaPreviewPath } : {}
8088
+ } : {},
7167
8089
  ...msg.reply ? {
7168
8090
  ReplyToBody: msg.reply.content.length > 50 ? msg.reply.content.slice(0, 50) + "..." : msg.reply.content,
7169
8091
  ReplyToSender: msg.reply.sender
@@ -7201,7 +8123,7 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
7201
8123
  payload.text ?? "",
7202
8124
  tableMode
7203
8125
  );
7204
- if (mediaList.length > 0) {
8126
+ if (mediaList.length > 0 && !isNewsappChat(chat)) {
7205
8127
  const media = [];
7206
8128
  for (const mediaUrl of mediaList) {
7207
8129
  const fsmod = await import("fs/promises");
@@ -7242,7 +8164,7 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
7242
8164
  inboundChars: lastMsg.rawBody.length,
7243
8165
  source: "openclaw"
7244
8166
  });
7245
- } else if (text) {
8167
+ } else if (text && !isNewsappChat(chat)) {
7246
8168
  const result = await client.sendMessage({
7247
8169
  chatId,
7248
8170
  text,
@@ -7289,7 +8211,7 @@ function bufferGroupHistory(groupHistory, chatId, pm, limit) {
7289
8211
  if (first) groupHistory.delete(first);
7290
8212
  }
7291
8213
  }
7292
- async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHandled, startupBaselines, pendingMessageScans, persistRetryState, account, cfg, log, skipOpen, groupHistory, groupHistoryLimit, catchup) {
8214
+ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHandled, startupBaselines, pendingMessageScans, persistRetryState, inboundLedger, mediaPipeline, emptyUnreadBackoff, account, cfg, log, skipOpen, groupHistory, groupHistoryLimit, catchup) {
7293
8215
  const core = getWeChatRuntime();
7294
8216
  const liveAccount = resolveWeChatAccount(cfg, account.accountId) ?? account;
7295
8217
  const chatId = chat.username ?? chat.id;
@@ -7374,12 +8296,37 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
7374
8296
  log?.info?.(
7375
8297
  `[wechat:${liveAccount.accountId}] ${chatId}: fetched ${messages.length} msgs, firstPoll=${firstPoll}, prevLastSeen=${prevLastSeen}, unreadCount=${chat.unreadCount}`
7376
8298
  );
7377
- if (messages.length === 0) return "skipped";
8299
+ if (messages.length === 0 && isNewsappChat(chat)) {
8300
+ const fallback = newsappFallbackMessage(chat);
8301
+ if (fallback) messages = [fallback];
8302
+ }
8303
+ if (messages.length === 0) {
8304
+ const skip = applyEmptyUnreadSkip(chatId, {
8305
+ unreadCount: chat.unreadCount,
8306
+ firstPoll,
8307
+ prevLastSeen,
8308
+ lastSeenId,
8309
+ backoff: emptyUnreadBackoff
8310
+ });
8311
+ if (skip) {
8312
+ log?.info?.(
8313
+ `[wechat:${liveAccount.accountId}] ${chatId}: empty unread fetch, firstPoll=${firstPoll}, unreadCount=${chat.unreadCount}; seedLastSeen=${skip.seededLastSeen}, backoff=${skip.backoffMs}ms`
8314
+ );
8315
+ }
8316
+ return "skipped";
8317
+ }
8318
+ emptyUnreadBackoff.delete(chatId);
7378
8319
  const enrichedBaseline = enrichStartupBaselineFromMessages(startupBaseline, messages);
7379
8320
  if (enrichedBaseline) {
7380
8321
  startupBaselines.set(chatId, enrichedBaseline);
7381
8322
  }
7382
- const selected = selectCursorMessages(
8323
+ const selected = isNewsappChat(chat) ? {
8324
+ firstPoll,
8325
+ prevLastSeen,
8326
+ messages,
8327
+ seedLastSeen: void 0,
8328
+ generationReset: false
8329
+ } : selectCursorMessages(
7383
8330
  chatId,
7384
8331
  chat,
7385
8332
  messages,
@@ -7430,17 +8377,17 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
7430
8377
  chatId
7431
8378
  );
7432
8379
  }
7433
- if (chat.unreadCount > 0) {
8380
+ if (chat.unreadCount > 0 && !isNewsappChat(chat)) {
7434
8381
  await openChatIfNeeded();
7435
8382
  }
7436
8383
  return "skipped";
7437
8384
  }
7438
- await openChatIfNeeded();
8385
+ if (!isNewsappChat(chat)) await openChatIfNeeded();
7439
8386
  const catchUpLimits = {
7440
8387
  maxMessages: liveAccount.catchUpMaxMessages,
7441
8388
  maxAgeMs: liveAccount.catchUpMaxAgeMs
7442
8389
  };
7443
- const isRecovery = firstPoll && startupBaseline === void 0 || skipOpen === true && isCatchUpBatch(newMessages, catchUpLimits);
8390
+ const isRecovery = !isNewsappChat(chat) && (firstPoll && startupBaseline === void 0 || skipOpen === true && isCatchUpBatch(newMessages, catchUpLimits));
7444
8391
  if (isRecovery) {
7445
8392
  const selection = selectCatchUpMessages(
7446
8393
  newMessages,
@@ -7473,13 +8420,20 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
7473
8420
  `[wechat:${liveAccount.accountId}] ${chatId}: ${newMessages.length} new msg(s) to process`
7474
8421
  );
7475
8422
  const processed = [];
8423
+ const eventIds = [];
7476
8424
  for (const msg of newMessages) {
8425
+ const eventId = inboundLedger.ensure(account.accountId, chatId, msg);
8426
+ if (!inboundLedger.shouldProcess(eventId)) continue;
8427
+ inboundLedger.markProcessing(eventId);
8428
+ eventIds.push(eventId);
7477
8429
  log?.info?.(
7478
8430
  `[wechat:${liveAccount.accountId}] Processing msg ${msg.localId}: type=${msg.type}, sender=${msg.sender}, isSelf=${msg.isSelf}, content=${(msg.content || "").slice(0, 50)}`
7479
8431
  );
7480
- const pm = await prepareMessage(client, msg, chatId, chat, liveAccount, policy, log);
8432
+ const pm = await prepareMessage(client, mediaPipeline, msg, chatId, chat, liveAccount, policy, log, skipOpen);
7481
8433
  if (pm) {
7482
8434
  processed.push(pm);
8435
+ } else {
8436
+ inboundLedger.markProcessed(eventId, "filtered");
7483
8437
  }
7484
8438
  }
7485
8439
  const isGroup = chatId.includes("@chatroom");
@@ -7496,6 +8450,7 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
7496
8450
  log?.info?.(`[wechat:${liveAccount.accountId}] Buffered ${processed.length} msg(s) for group history in ${chatId}`);
7497
8451
  const maxId2 = Math.max(...newMessages.map((m) => m.localId));
7498
8452
  advanceLastSeen(newMessages.filter((message) => message.localId <= maxId2));
8453
+ for (const eventId of eventIds) inboundLedger.markProcessed(eventId, "buffered");
7499
8454
  return "skipped";
7500
8455
  }
7501
8456
  if (hasMention) {
@@ -7562,6 +8517,7 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
7562
8517
  );
7563
8518
  const maxId2 = Math.max(...newMessages.map((m) => m.localId));
7564
8519
  advanceLastSeen(newMessages.filter((message) => message.localId <= maxId2));
8520
+ for (const eventId of eventIds) inboundLedger.markProcessed(eventId, "stale");
7565
8521
  return "skipped";
7566
8522
  }
7567
8523
  const fold = shouldFoldSegments(catchup?.isReconnect ?? false) && !hasControlCommandInWindow;
@@ -7571,6 +8527,13 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
7571
8527
  );
7572
8528
  let allDispatched = true;
7573
8529
  const successfulSegmentLastLocalIds = [];
8530
+ const segmentEventIds = (segment) => {
8531
+ const keys = new Set(segment.map((pm) => pm.msg.localId));
8532
+ return eventIds.filter((eventId) => {
8533
+ const record = inboundLedger.get(eventId);
8534
+ return record !== void 0 && keys.has(record.localId);
8535
+ });
8536
+ };
7574
8537
  for (let i = 0; i < segments.length; i++) {
7575
8538
  const remaining = segments.length - i - 1;
7576
8539
  const dispatched = await dispatchSegment(
@@ -7588,12 +8551,19 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
7588
8551
  );
7589
8552
  if (!dispatched) {
7590
8553
  allDispatched = false;
8554
+ inboundLedger.markFailedBatch(segmentEventIds(segments[i]), "DISPATCH_FAILED");
8555
+ const remainingIds = segments.slice(i + 1).flatMap(segmentEventIds);
8556
+ inboundLedger.markFailedBatch(remainingIds, "DISPATCH_NOT_ATTEMPTED");
7591
8557
  break;
7592
8558
  }
8559
+ inboundLedger.markProcessedBatch(segmentEventIds(segments[i]), "dispatched");
7593
8560
  successfulSegmentLastLocalIds.push(
7594
8561
  Math.max(...segments[i].map((pm) => pm.msg.localId))
7595
8562
  );
7596
8563
  }
8564
+ if (allDispatched) {
8565
+ for (const eventId of eventIds) inboundLedger.markProcessed(eventId, "dispatched");
8566
+ }
7597
8567
  if (clearBufferedHistory && allDispatched && groupHistory) {
7598
8568
  groupHistory.set(chatId, []);
7599
8569
  }
@@ -7854,6 +8824,14 @@ function buildOpenClawConfirmedSend(params) {
7854
8824
  };
7855
8825
  }
7856
8826
 
8827
+ // src/group-members-consent.ts
8828
+ function hasExplicitGroupMemberConsent(params) {
8829
+ return params.confirmed === true;
8830
+ }
8831
+ function groupMemberErrorCode(error) {
8832
+ return error instanceof WeChatHttpError && error.errorCode ? error.errorCode : "GROUP_MEMBERS_FAILED";
8833
+ }
8834
+
7857
8835
  // src/agent-tools.ts
7858
8836
  function createWeChatConfirmedSendTool(account) {
7859
8837
  const client = new WeChatClient({
@@ -7901,6 +8879,52 @@ function createWeChatConfirmedSendTool(account) {
7901
8879
  }
7902
8880
  };
7903
8881
  }
8882
+ function createWeChatGroupMembersTool(account) {
8883
+ const client = new WeChatClient({ baseUrl: account.serverUrl, token: account.token });
8884
+ return {
8885
+ label: "WeChat Group Members",
8886
+ name: "wechat_group_members",
8887
+ description: "Read one bounded page of group members by stable group id. This returns personal information; call only when the user explicitly asks for a specific group, and do not echo more fields than needed.",
8888
+ parameters: {
8889
+ type: "object",
8890
+ additionalProperties: false,
8891
+ properties: {
8892
+ groupId: { type: "string", description: "Stable group id ending in @chatroom" },
8893
+ limit: { type: "number", minimum: 1, maximum: 50 },
8894
+ cursor: { type: "string" },
8895
+ confirmed: { type: "boolean", description: "Must be true after the user explicitly requests this group's personal member data." }
8896
+ },
8897
+ required: ["groupId", "confirmed"]
8898
+ },
8899
+ execute: async (_toolCallId, params) => {
8900
+ const args = params;
8901
+ const groupId = String(args.groupId ?? "").trim();
8902
+ if (!hasExplicitGroupMemberConsent(args)) {
8903
+ return {
8904
+ content: [{ type: "text", text: "Explicit user confirmation is required before reading group member personal data." }],
8905
+ details: { success: false, errorCode: "EXPLICIT_CONFIRMATION_REQUIRED" }
8906
+ };
8907
+ }
8908
+ const limit = typeof args.limit === "number" ? Math.trunc(args.limit) : 25;
8909
+ try {
8910
+ const page = await client.listGroupMembersPage(
8911
+ groupId,
8912
+ Math.min(50, Math.max(1, limit)),
8913
+ typeof args.cursor === "string" ? args.cursor : void 0
8914
+ );
8915
+ return {
8916
+ content: [{ type: "text", text: `Retrieved ${page.items.length} group member(s).` }],
8917
+ details: page
8918
+ };
8919
+ } catch (error) {
8920
+ return {
8921
+ content: [{ type: "text", text: "Group member lookup failed." }],
8922
+ details: { error: true, code: groupMemberErrorCode(error) }
8923
+ };
8924
+ }
8925
+ }
8926
+ };
8927
+ }
7904
8928
  function createWeChatLoginTool(account) {
7905
8929
  const client = new WeChatClient({
7906
8930
  baseUrl: account.serverUrl,
@@ -8434,7 +9458,11 @@ Error: ${event.message}`);
8434
9458
  agentTools: (({ cfg }) => {
8435
9459
  const account = resolveWeChatAccount(cfg);
8436
9460
  if (!account?.serverUrl) return [];
8437
- return [createWeChatLoginTool(account), createWeChatConfirmedSendTool(account)];
9461
+ return [
9462
+ createWeChatLoginTool(account),
9463
+ createWeChatConfirmedSendTool(account),
9464
+ createWeChatGroupMembersTool(account)
9465
+ ];
8438
9466
  }),
8439
9467
  // ---- Directory adapter ----
8440
9468
  directory: {