@kyan-du/agent-wechat-openclaw 0.13.3 → 0.14.0

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 +940 -49
  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),
@@ -5452,6 +5534,12 @@ var WeChatClient = class {
5452
5534
  async openChat(chatId, clearUnreads) {
5453
5535
  return this.post(`/api/chats/${encodeURIComponent(chatId)}/open${qs({ clearUnreads })}`);
5454
5536
  }
5537
+ async listGroupMembersPage(groupId, limit, cursor) {
5538
+ const page = groupMembersPageSchema.parse(await this.get(`/api/groups/${encodeURIComponent(groupId)}/members${qs({ limit, cursor })}`));
5539
+ if (page.errorCode)
5540
+ throw new WeChatHttpError(400, "Bad Request", page.errorCode, page.errorCode);
5541
+ return { ...page, nextCursor: page.nextCursor ?? void 0 };
5542
+ }
5455
5543
  // ---- Contacts ----
5456
5544
  async listContactsPage(limit, cursor) {
5457
5545
  const page = await this.get(`/api/contacts${qs({ limit, cursor })}`);
@@ -5475,6 +5563,17 @@ var WeChatClient = class {
5475
5563
  async listMessages(chatId, limit) {
5476
5564
  return (await this.listMessagesPage(chatId, limit)).items;
5477
5565
  }
5566
+ async syncChat(chatId, options = {}) {
5567
+ 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 })}`);
5568
+ if (raw && typeof raw === "object" && typeof raw.errorCode === "string") {
5569
+ const errorCode = raw.errorCode;
5570
+ throw new WeChatHttpError(400, "Bad Request", errorCode, errorCode);
5571
+ }
5572
+ const page = chatSyncPageSchema.parse(raw);
5573
+ if (page.errorCode)
5574
+ throw new WeChatHttpError(400, "Bad Request", page.errorCode, page.errorCode);
5575
+ return { ...page, nextCursor: page.nextCursor ?? void 0 };
5576
+ }
5478
5577
  async getMedia(chatId, localId) {
5479
5578
  return this.get(`/api/messages/${encodeURIComponent(chatId)}/media/${localId}`);
5480
5579
  }
@@ -5558,7 +5657,7 @@ var WeChatClient = class {
5558
5657
  };
5559
5658
 
5560
5659
  // src/monitor.ts
5561
- import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
5660
+ import { createChannelMessageReplyPipeline as createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
5562
5661
 
5563
5662
  // src/catch-up.ts
5564
5663
  function recoveryCursor(selection) {
@@ -6073,7 +6172,7 @@ function recordResetRetryFailure(state, chatId, now = Date.now()) {
6073
6172
  });
6074
6173
  persist(state);
6075
6174
  }
6076
- function monitorChatsToProcess(chats, state, now = Date.now()) {
6175
+ function monitorChatsToProcess(chats, state, now = Date.now(), shouldProcessChat = () => true) {
6077
6176
  const result = /* @__PURE__ */ new Map();
6078
6177
  for (const pending of state.pendingMessageScans.values()) {
6079
6178
  const chatId = pending.chat.username ?? pending.chat.id;
@@ -6081,7 +6180,7 @@ function monitorChatsToProcess(chats, state, now = Date.now()) {
6081
6180
  }
6082
6181
  for (const chat of chats) {
6083
6182
  const chatId = chat.username ?? chat.id;
6084
- if (chat.unreadCount > 0 || state.pendingMessageScans.has(chatId)) result.set(chatId, chat);
6183
+ if (shouldProcessChat(chat) && (chat.unreadCount > 0 || state.pendingMessageScans.has(chatId))) result.set(chatId, chat);
6085
6184
  }
6086
6185
  return result;
6087
6186
  }
@@ -6366,6 +6465,9 @@ var MIME_BY_FORMAT = {
6366
6465
  webp: "image/webp",
6367
6466
  gif: "image/gif",
6368
6467
  mp3: "audio/mpeg",
6468
+ wav: "audio/wav",
6469
+ ogg: "audio/ogg",
6470
+ oga: "audio/ogg",
6369
6471
  pdf: "application/pdf",
6370
6472
  doc: "application/msword",
6371
6473
  docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@@ -6374,9 +6476,14 @@ var MIME_BY_FORMAT = {
6374
6476
  ppt: "application/vnd.ms-powerpoint",
6375
6477
  pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
6376
6478
  zip: "application/zip",
6377
- txt: "text/plain"
6479
+ txt: "text/plain",
6480
+ mp4: "video/mp4",
6481
+ webm: "video/webm",
6482
+ mov: "video/quicktime"
6378
6483
  };
6379
6484
  var MAX_IMAGE_BYTES = 20 * 1024 * 1024;
6485
+ var MAX_FILE_BYTES = 25 * 1024 * 1024;
6486
+ var MAX_FILENAME_CHARS = 180;
6380
6487
  var MAX_IMAGE_PIXELS = 4e7;
6381
6488
  var MAX_IMAGE_DIMENSION = 32768;
6382
6489
  var MAX_DECODED_IMAGE_BYTES = MAX_IMAGE_PIXELS * 4;
@@ -6396,6 +6503,29 @@ var SHARP_INPUT_OPTIONS = {
6396
6503
  };
6397
6504
  sharp.cache({ files: 0, items: 0, memory: 32 });
6398
6505
  sharp.concurrency(1);
6506
+ function safeInboundFilename(raw) {
6507
+ const base = raw.replace(/\\/g, "/").split("/").pop() ?? "";
6508
+ const cleaned = [...base].filter((char) => char >= " " && char !== "\x7F" && char !== "/" && char !== "\\").slice(0, MAX_FILENAME_CHARS).join("").replace(/^[.\s]+|[.\s]+$/g, "");
6509
+ return cleaned || void 0;
6510
+ }
6511
+ var SUPPORTED_FILE_FORMATS = /* @__PURE__ */ new Set(["pdf"]);
6512
+ function documentMimeFromMagic(buf) {
6513
+ if (buf.length >= 5 && buf.toString("ascii", 0, 5) === "%PDF-") return "application/pdf";
6514
+ return void 0;
6515
+ }
6516
+ function binaryMimeFromMagic(buf) {
6517
+ if (buf.length >= 3 && buf.subarray(0, 3).equals(Buffer.from("ID3", "ascii"))) return "audio/mpeg";
6518
+ if (buf.length >= 2 && buf[0] === 255 && (buf[1] & 224) === 224) return "audio/mpeg";
6519
+ if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WAVE") return "audio/wav";
6520
+ if (buf.length >= 4 && buf.toString("ascii", 0, 4) === "OggS") return "audio/ogg";
6521
+ if (buf.length >= 12 && buf.toString("ascii", 4, 8) === "ftyp") {
6522
+ const brand = buf.toString("ascii", 8, 12);
6523
+ if (["isom", "iso2", "mp41", "mp42", "avc1"].includes(brand)) return "video/mp4";
6524
+ if (brand === "qt ") return "video/quicktime";
6525
+ }
6526
+ if (buf.length >= 4 && buf.subarray(0, 4).equals(Buffer.from([26, 69, 223, 163]))) return "video/webm";
6527
+ return void 0;
6528
+ }
6399
6529
  function imageMimeFromMagic(buf) {
6400
6530
  if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "image/jpeg";
6401
6531
  if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png";
@@ -6621,44 +6751,645 @@ async function validateInboundMedia(result) {
6621
6751
  }
6622
6752
  const buffer = Buffer.from(normalized, "base64");
6623
6753
  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"}`;
6754
+ const format = result.format.toLowerCase();
6755
+ const declared = MIME_BY_FORMAT[format] ?? "application/octet-stream";
6625
6756
  if (result.type === "image") {
6626
6757
  const detected = imageMimeFromMagic(buffer);
6627
6758
  if (!detected || detected !== declared) return { ok: false, code: "MEDIA_MAGIC_MISMATCH" };
6628
6759
  const invalidCode = await validateDecodedImage(buffer, detected);
6629
6760
  if (invalidCode) return { ok: false, code: invalidCode };
6761
+ } else if (result.type === "voice" || result.type === "video") {
6762
+ if (buffer.length > MAX_FILE_BYTES) return { ok: false, code: "MEDIA_FILE_TOO_LARGE" };
6763
+ const detected = binaryMimeFromMagic(buffer);
6764
+ if (!detected || detected !== declared) return { ok: false, code: "MEDIA_MAGIC_MISMATCH" };
6765
+ } else if (result.type === "file") {
6766
+ if (buffer.length > MAX_FILE_BYTES) return { ok: false, code: "MEDIA_FILE_TOO_LARGE" };
6767
+ if (!safeInboundFilename(result.filename)) return { ok: false, code: "MEDIA_FILENAME_INVALID" };
6768
+ if (!SUPPORTED_FILE_FORMATS.has(format)) return { ok: false, code: "MEDIA_FILE_TYPE_MISMATCH" };
6769
+ if (documentMimeFromMagic(buffer) !== "application/pdf") {
6770
+ return { ok: false, code: "MEDIA_FILE_TYPE_MISMATCH" };
6771
+ }
6630
6772
  }
6631
6773
  return { ok: true, value: { buffer, mime: declared } };
6632
6774
  }
6633
6775
  function safeBodyAfterKnownMediaFailure(baseType, content) {
6634
6776
  return baseType === 3 ? "[Image unavailable]" : content;
6635
6777
  }
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
6778
 
6650
6779
  // src/inbound-media-poll.ts
6651
- async function pollMedia(client, chatId, localId, log, maxAttempts = 15, intervalMs = 1e3) {
6780
+ var RETRYABLE_MEDIA_ERRORS = /* @__PURE__ */ new Set([
6781
+ "MEDIA_NOT_DOWNLOADED",
6782
+ "IMAGE_RESOURCE_UNAVAILABLE",
6783
+ "FILE_NOT_DOWNLOADED",
6784
+ "FILE_NOT_STABLE",
6785
+ "VOICE_NOT_DOWNLOADED",
6786
+ "IMAGE_NOT_STABLE"
6787
+ ]);
6788
+ var DEFAULT_MEDIA_POLL_ATTEMPTS = 30;
6789
+ var DEFAULT_MEDIA_POLL_INTERVAL_MS = 1e3;
6790
+ function isRetryable(result) {
6791
+ if (result.data !== void 0) return false;
6792
+ if (result.errorCode !== void 0) return RETRYABLE_MEDIA_ERRORS.has(result.errorCode);
6793
+ return result.type === "pending";
6794
+ }
6795
+ async function pollMedia(client, chatId, localId, log, maxAttempts = DEFAULT_MEDIA_POLL_ATTEMPTS, intervalMs = DEFAULT_MEDIA_POLL_INTERVAL_MS) {
6796
+ let lastResult;
6797
+ const startedAt = Date.now();
6652
6798
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
6653
6799
  const result = await client.getMedia(chatId, localId);
6654
- if (result.type === "unsupported") return null;
6655
- if (result.data) return result;
6800
+ lastResult = result;
6801
+ if (result.type === "unsupported") return result;
6802
+ if (result.data || !isRetryable(result)) return result;
6656
6803
  if (attempt < maxAttempts) {
6657
- log?.info?.(`[wechat:media] pending attempt=${attempt}/${maxAttempts}`);
6804
+ log?.info?.(`[wechat:media] pending attempt=${attempt}/${maxAttempts} elapsedMs=${Date.now() - startedAt}`);
6658
6805
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
6659
6806
  }
6660
6807
  }
6661
- return null;
6808
+ log?.info?.(`[wechat:media] pending exhausted attempts=${maxAttempts} elapsedMs=${Date.now() - startedAt}`);
6809
+ return lastResult ?? null;
6810
+ }
6811
+
6812
+ // src/monitor-ledger.ts
6813
+ import { createHash } from "node:crypto";
6814
+ import {
6815
+ closeSync as closeSync3,
6816
+ existsSync as existsSync3,
6817
+ fsyncSync as fsyncSync3,
6818
+ openSync as openSync3
6819
+ } from "node:fs";
6820
+ import { homedir as homedir2 } from "node:os";
6821
+ import { dirname as dirname3, join as join2 } from "node:path";
6822
+
6823
+ // src/durable-store.ts
6824
+ import { randomUUID as randomUUID2 } from "node:crypto";
6825
+ import {
6826
+ closeSync as closeSync2,
6827
+ existsSync as existsSync2,
6828
+ fsyncSync as fsyncSync2,
6829
+ mkdirSync as mkdirSync2,
6830
+ openSync as openSync2,
6831
+ readFileSync as readFileSync2,
6832
+ renameSync as renameSync2,
6833
+ rmSync as rmSync2,
6834
+ statSync,
6835
+ writeFileSync as writeFileSync2
6836
+ } from "node:fs";
6837
+ import { dirname as dirname2, isAbsolute } from "node:path";
6838
+ var MAX_DURABLE_JSON_BYTES = 8 * 1024 * 1024;
6839
+ var LOCK_WAIT_MS = 5e3;
6840
+ var LOCK_STALE_MS = 3e4;
6841
+ var DurableStoreError = class extends Error {
6842
+ code = "DURABLE_STORE_IO_FAILED";
6843
+ constructor(message, options) {
6844
+ super(message, options);
6845
+ this.name = "DurableStoreError";
6846
+ }
6847
+ };
6848
+ function assertTrustedPath(path, root) {
6849
+ if (!isAbsolute(path) || path.includes("\0")) {
6850
+ throw new DurableStoreError("durable store paths must be absolute and contain no null bytes");
6851
+ }
6852
+ if (root) {
6853
+ if (!isAbsolute(root) || path !== root && !path.startsWith(`${root}/`)) {
6854
+ throw new DurableStoreError("durable store path is outside the approved state root");
6855
+ }
6856
+ for (const part of path.slice(root.length).split("/")) {
6857
+ if (!part) continue;
6858
+ const candidate = path.slice(0, path.indexOf(part) + part.length);
6859
+ try {
6860
+ if (statSync(candidate).isSymbolicLink()) throw new DurableStoreError("durable store path contains a symlink");
6861
+ } catch (error) {
6862
+ if (error instanceof DurableStoreError) throw error;
6863
+ }
6864
+ }
6865
+ }
6866
+ }
6867
+ function syncPath2(path) {
6868
+ const fd = openSync2(path, "r");
6869
+ try {
6870
+ fsyncSync2(fd);
6871
+ } finally {
6872
+ closeSync2(fd);
6873
+ }
6874
+ }
6875
+ function sleepSync(ms) {
6876
+ const buffer = new SharedArrayBuffer(4);
6877
+ Atomics.wait(new Int32Array(buffer), 0, 0, ms);
6878
+ }
6879
+ function lockPath(path) {
6880
+ return `${path}.lock`;
6881
+ }
6882
+ function acquireLock(path) {
6883
+ assertTrustedPath(path);
6884
+ mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
6885
+ const lock = lockPath(path);
6886
+ const deadline = Date.now() + LOCK_WAIT_MS;
6887
+ while (Date.now() < deadline) {
6888
+ try {
6889
+ writeFileSync2(lock, JSON.stringify({ version: 1, pid: process.pid, createdAt: Date.now() }), { flag: "wx", mode: 384 });
6890
+ syncPath2(lock);
6891
+ return lock;
6892
+ } catch (error) {
6893
+ if (error.code !== "EEXIST") {
6894
+ throw new DurableStoreError(`durable store lock failed: ${path}`, { cause: error });
6895
+ }
6896
+ try {
6897
+ if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) rmSync2(lock, { force: true });
6898
+ } catch {
6899
+ }
6900
+ sleepSync(10);
6901
+ }
6902
+ }
6903
+ throw new DurableStoreError(`durable store lock timeout: ${path}`);
6904
+ }
6905
+ function releaseLock(lock) {
6906
+ try {
6907
+ rmSync2(lock, { force: true });
6908
+ } catch {
6909
+ }
6910
+ }
6911
+ function withDurableJsonLock(path, operation) {
6912
+ const lock = acquireLock(path);
6913
+ try {
6914
+ return operation();
6915
+ } finally {
6916
+ releaseLock(lock);
6917
+ }
6918
+ }
6919
+ function serialize(value) {
6920
+ const json = JSON.stringify(value);
6921
+ if (Buffer.byteLength(json, "utf8") > MAX_DURABLE_JSON_BYTES) {
6922
+ throw new DurableStoreError(`durable store value exceeds ${MAX_DURABLE_JSON_BYTES} bytes`);
6923
+ }
6924
+ return json;
6925
+ }
6926
+ function writeDurableJsonLocked(path, value) {
6927
+ const temp = `${path}.tmp-${process.pid}-${randomUUID2()}`;
6928
+ try {
6929
+ writeFileSync2(temp, serialize(value), { mode: 384 });
6930
+ syncPath2(temp);
6931
+ renameSync2(temp, path);
6932
+ syncPath2(dirname2(path));
6933
+ } catch (error) {
6934
+ try {
6935
+ rmSync2(temp, { force: true });
6936
+ } catch {
6937
+ }
6938
+ throw error;
6939
+ }
6940
+ }
6941
+ function writeUnlocked(path, value) {
6942
+ writeDurableJsonLocked(path, value);
6943
+ }
6944
+ function updateDurableJson(path, update, root) {
6945
+ assertTrustedPath(path, root);
6946
+ try {
6947
+ return withDurableJsonLock(path, () => {
6948
+ const current = existsSync2(path) ? JSON.parse(readFileSync2(path, "utf8")) : void 0;
6949
+ const next = update(current);
6950
+ writeUnlocked(path, next);
6951
+ return next;
6952
+ });
6953
+ } catch (error) {
6954
+ if (error instanceof DurableStoreError) throw error;
6955
+ throw new DurableStoreError(`durable store update failed: ${path}`, { cause: error });
6956
+ }
6957
+ }
6958
+ function readDurableJson(path) {
6959
+ assertTrustedPath(path);
6960
+ if (!existsSync2(path)) return void 0;
6961
+ try {
6962
+ return JSON.parse(readFileSync2(path, "utf8"));
6963
+ } catch (error) {
6964
+ throw new DurableStoreError(`durable store read failed: ${path}`, { cause: error });
6965
+ }
6966
+ }
6967
+ function quarantineDurableJson(path, blockerPath3) {
6968
+ assertTrustedPath(path);
6969
+ assertTrustedPath(blockerPath3);
6970
+ const quarantinePath3 = `${path}.corrupt-${Date.now()}-${randomUUID2()}`;
6971
+ const markerPath = `${path}.quarantine`;
6972
+ try {
6973
+ withDurableJsonLock(path, () => {
6974
+ writeDurableJsonLocked(markerPath, { version: 1, quarantine: quarantinePath3, blocker: blockerPath3 });
6975
+ renameSync2(path, quarantinePath3);
6976
+ syncPath2(dirname2(path));
6977
+ writeDurableJsonLocked(blockerPath3, { version: 1, quarantine: quarantinePath3 });
6978
+ rmSync2(markerPath, { force: true });
6979
+ syncPath2(dirname2(path));
6980
+ });
6981
+ return quarantinePath3;
6982
+ } catch (error) {
6983
+ throw new DurableStoreError(`durable store quarantine failed: ${path}`, { cause: error });
6984
+ }
6985
+ }
6986
+ function recoverDurableQuarantine(path) {
6987
+ const markerPath = `${path}.quarantine`;
6988
+ if (!existsSync2(markerPath)) return;
6989
+ try {
6990
+ const marker = JSON.parse(readFileSync2(markerPath, "utf8"));
6991
+ if (typeof marker.quarantine !== "string" || typeof marker.blocker !== "string") throw new Error("invalid quarantine marker");
6992
+ withDurableJsonLock(path, () => {
6993
+ if (existsSync2(path)) renameSync2(path, marker.quarantine);
6994
+ writeDurableJsonLocked(marker.blocker, { version: 1, quarantine: marker.quarantine });
6995
+ rmSync2(markerPath, { force: true });
6996
+ syncPath2(dirname2(path));
6997
+ });
6998
+ } catch (error) {
6999
+ throw new DurableStoreError(`durable store quarantine recovery failed: ${path}`, { cause: error });
7000
+ }
7001
+ }
7002
+
7003
+ // src/monitor-ledger.ts
7004
+ var MAX_INBOUND_LEDGER_ENTRIES = 1e4;
7005
+ var MAX_INBOUND_EVENT_ATTEMPTS = 5;
7006
+ var MAX_INBOUND_RETRY_DELAY_MS = 6e4;
7007
+ var InboundLedgerStateError = class extends Error {
7008
+ code = "INBOUND_LEDGER_BLOCKED";
7009
+ constructor(message) {
7010
+ super(message);
7011
+ this.name = "InboundLedgerStateError";
7012
+ }
7013
+ };
7014
+ function statePath(accountId, stateDir) {
7015
+ const root = stateDir ?? process.env.OPENCLAW_STATE_DIR?.trim() ?? join2(homedir2(), ".openclaw");
7016
+ const safeAccount = accountId.replace(/[^A-Za-z0-9._-]/g, "_");
7017
+ return join2(root, "wechat", `inbound-ledger-${safeAccount}.json`);
7018
+ }
7019
+ function quarantinePath2(path) {
7020
+ return `${path}.corrupt`;
7021
+ }
7022
+ function blockerPath2(path) {
7023
+ return `${path}.blocked`;
7024
+ }
7025
+ function validRecord(value) {
7026
+ if (!value || typeof value !== "object") return false;
7027
+ const row = value;
7028
+ 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)));
7029
+ }
7030
+ function validateFile(value) {
7031
+ if (!value || typeof value !== "object") throw new InboundLedgerStateError("inbound ledger is not an object");
7032
+ const row = value;
7033
+ if (row.version !== 1 || !Array.isArray(row.entries) || row.entries.length > MAX_INBOUND_LEDGER_ENTRIES) {
7034
+ throw new InboundLedgerStateError("inbound ledger schema or capacity is invalid");
7035
+ }
7036
+ const entries = row.entries;
7037
+ if (!entries.every(validRecord)) throw new InboundLedgerStateError("inbound ledger contains an invalid event");
7038
+ const ids = new Set(entries.map((entry) => entry.eventId));
7039
+ if (ids.size !== entries.length) throw new InboundLedgerStateError("inbound ledger contains duplicate events");
7040
+ return { version: 1, entries };
7041
+ }
7042
+ function loadFile(path) {
7043
+ if (existsSync3(blockerPath2(path))) {
7044
+ throw new InboundLedgerStateError(`inbound ledger is blocked: ${blockerPath2(path)}`);
7045
+ }
7046
+ if (!existsSync3(path)) return { version: 1, entries: [] };
7047
+ try {
7048
+ const parsed = readDurableJson(path);
7049
+ if (parsed === void 0) return { version: 1, entries: [] };
7050
+ return validateFile(parsed);
7051
+ } catch (error) {
7052
+ const quarantine = quarantinePath2(path);
7053
+ try {
7054
+ quarantineDurableJson(path, blockerPath2(path));
7055
+ } catch (quarantineError) {
7056
+ throw new InboundLedgerStateError(`inbound ledger quarantine failed: ${String(quarantineError)}`);
7057
+ }
7058
+ throw new InboundLedgerStateError(`inbound ledger quarantined at ${quarantine}: ${String(error)}`);
7059
+ }
7060
+ }
7061
+ function inboundEventId(accountId, chatId, message) {
7062
+ return createHash("sha256").update(`${accountId}\0${chatId}\0${cursorMessageKey(message)}`).digest("hex");
7063
+ }
7064
+ var InboundEventLedger = class {
7065
+ path;
7066
+ entries = /* @__PURE__ */ new Map();
7067
+ constructor(accountId, stateDir) {
7068
+ this.path = statePath(accountId, stateDir);
7069
+ recoverDurableQuarantine(this.path);
7070
+ for (const entry of loadFile(this.path).entries) this.entries.set(entry.eventId, entry);
7071
+ }
7072
+ persist() {
7073
+ const localEntries = [...this.entries.values()];
7074
+ const next = updateDurableJson(this.path, (current) => {
7075
+ const merged = new Map((current?.entries ?? []).map((entry) => [entry.eventId, entry]));
7076
+ for (const entry of localEntries) merged.set(entry.eventId, entry);
7077
+ const entries = [...merged.values()].sort((a, b) => a.updatedAt - b.updatedAt || a.eventId.localeCompare(b.eventId));
7078
+ if (entries.length > MAX_INBOUND_LEDGER_ENTRIES) {
7079
+ const retained = entries.filter((entry) => entry.status !== "processed");
7080
+ if (retained.length > MAX_INBOUND_LEDGER_ENTRIES) throw new InboundLedgerStateError("inbound ledger capacity exceeded");
7081
+ return { version: 1, entries: retained };
7082
+ }
7083
+ return { version: 1, entries };
7084
+ });
7085
+ this.entries.clear();
7086
+ for (const entry of next.entries) this.entries.set(entry.eventId, entry);
7087
+ }
7088
+ get(eventId) {
7089
+ const entry = this.entries.get(eventId);
7090
+ return entry ? { ...entry } : void 0;
7091
+ }
7092
+ ensure(accountId, chatId, message, now = Date.now()) {
7093
+ const eventId = inboundEventId(accountId, chatId, message);
7094
+ if (!this.entries.has(eventId)) {
7095
+ this.entries.set(eventId, {
7096
+ eventId,
7097
+ chatId,
7098
+ localId: message.localId,
7099
+ messageKey: cursorMessageKey(message),
7100
+ status: "pending",
7101
+ attempts: 0,
7102
+ createdAt: now,
7103
+ updatedAt: now
7104
+ });
7105
+ this.persist();
7106
+ }
7107
+ return eventId;
7108
+ }
7109
+ shouldProcess(eventId, now = Date.now()) {
7110
+ const entry = this.entries.get(eventId);
7111
+ if (!entry || entry.status === "processed" || entry.status === "dead_letter") return false;
7112
+ return entry.status !== "failed" || (entry.nextRetryAt ?? 0) <= now;
7113
+ }
7114
+ markProcessing(eventId, now = Date.now()) {
7115
+ const entry = this.entries.get(eventId);
7116
+ if (!entry || !this.shouldProcess(eventId, now)) return;
7117
+ entry.status = "processing";
7118
+ entry.attempts += 1;
7119
+ entry.updatedAt = now;
7120
+ entry.nextRetryAt = void 0;
7121
+ this.persist();
7122
+ }
7123
+ markProcessed(eventId, outcome, now = Date.now()) {
7124
+ const entry = this.entries.get(eventId);
7125
+ if (!entry) return;
7126
+ entry.status = "processed";
7127
+ entry.outcome = outcome;
7128
+ entry.updatedAt = now;
7129
+ entry.nextRetryAt = void 0;
7130
+ entry.lastErrorCode = void 0;
7131
+ this.persist();
7132
+ }
7133
+ markFailed(eventId, errorCode, now = Date.now()) {
7134
+ const entry = this.entries.get(eventId);
7135
+ if (!entry) return;
7136
+ entry.updatedAt = now;
7137
+ entry.lastErrorCode = errorCode.slice(0, 128);
7138
+ if (entry.attempts >= MAX_INBOUND_EVENT_ATTEMPTS) {
7139
+ entry.status = "dead_letter";
7140
+ entry.nextRetryAt = void 0;
7141
+ } else {
7142
+ entry.status = "failed";
7143
+ entry.nextRetryAt = now + Math.min(MAX_INBOUND_RETRY_DELAY_MS, 1e3 * 2 ** Math.min(entry.attempts - 1, 6));
7144
+ }
7145
+ this.persist();
7146
+ }
7147
+ list(filter) {
7148
+ 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 }));
7149
+ }
7150
+ markProcessedBatch(eventIds, outcome, now = Date.now()) {
7151
+ for (const eventId of eventIds) this.markProcessed(eventId, outcome, now);
7152
+ }
7153
+ markFailedBatch(eventIds, errorCode, now = Date.now()) {
7154
+ for (const eventId of eventIds) this.markFailed(eventId, errorCode, now);
7155
+ }
7156
+ replay(eventId, now = Date.now()) {
7157
+ const entry = this.entries.get(eventId);
7158
+ if (!entry || entry.status === "processed") return false;
7159
+ entry.status = "pending";
7160
+ entry.updatedAt = now;
7161
+ entry.nextRetryAt = now;
7162
+ entry.lastErrorCode = void 0;
7163
+ this.persist();
7164
+ return true;
7165
+ }
7166
+ };
7167
+ function loadInboundEventLedger(accountId, stateDir) {
7168
+ return new InboundEventLedger(accountId, stateDir);
7169
+ }
7170
+
7171
+ // src/media-pipeline.ts
7172
+ import { createHash as createHash2 } from "node:crypto";
7173
+ 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";
7174
+ import { homedir as homedir3 } from "node:os";
7175
+ import { dirname as dirname4, join as join3 } from "node:path";
7176
+ import sharp2 from "sharp";
7177
+ var MAX_MEDIA_PIPELINE_ENTRIES = 5e3;
7178
+ var MEDIA_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
7179
+ function pipelinePath(accountId, stateDir) {
7180
+ const root = stateDir ?? process.env.OPENCLAW_STATE_DIR?.trim() ?? join3(homedir3(), ".openclaw");
7181
+ const safeAccount = accountId.replace(/[^A-Za-z0-9._-]/g, "_");
7182
+ return join3(root, "wechat", `media-pipeline-${safeAccount}.json`);
7183
+ }
7184
+ function syncFile(path) {
7185
+ const fd = openSync4(path, "r");
7186
+ try {
7187
+ fsyncSync4(fd);
7188
+ } finally {
7189
+ closeSync4(fd);
7190
+ }
7191
+ }
7192
+ function syncParent2(path) {
7193
+ syncFile(dirname4(path));
7194
+ }
7195
+ function writeState(path, file) {
7196
+ mkdirSync4(dirname4(path), { recursive: true, mode: 448 });
7197
+ const temp = `${path}.tmp-${process.pid}`;
7198
+ writeFileSync4(temp, JSON.stringify(file), { mode: 384 });
7199
+ syncFile(temp);
7200
+ renameSync4(temp, path);
7201
+ syncParent2(path);
7202
+ }
7203
+ function validRecord2(value) {
7204
+ if (!value || typeof value !== "object") return false;
7205
+ const row = value;
7206
+ 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) => {
7207
+ if (!binding || typeof binding !== "object") return false;
7208
+ const item = binding;
7209
+ 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);
7210
+ }) && (row.extraction === "not_configured" || row.extraction === "not_applicable") && typeof row.createdAt === "number" && Number.isFinite(row.createdAt) && typeof row.updatedAt === "number" && Number.isFinite(row.updatedAt);
7211
+ }
7212
+ function readState(path) {
7213
+ if (!existsSync4(path)) return { version: 1, entries: [] };
7214
+ try {
7215
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
7216
+ 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");
7217
+ return { version: 1, entries: parsed.entries };
7218
+ } catch (error) {
7219
+ throw new Error(`media pipeline state is invalid: ${String(error)}`);
7220
+ }
7221
+ }
7222
+ function safeFilename(raw) {
7223
+ const base = raw.replace(/\\/g, "/").split("/").pop() ?? "attachment";
7224
+ const cleaned = [...base].filter((char) => char >= " " && char !== "\x7F").join("").trim();
7225
+ return (cleaned || "attachment").slice(0, 180);
7226
+ }
7227
+ function previewFilename(filename) {
7228
+ const base = safeFilename(filename).replace(/\.[^.]+$/, "");
7229
+ return `${base || "attachment"}.preview.jpg`;
7230
+ }
7231
+ var MediaPipeline = class {
7232
+ path;
7233
+ entries = /* @__PURE__ */ new Map();
7234
+ constructor(accountId, stateDir) {
7235
+ this.path = pipelinePath(accountId, stateDir);
7236
+ for (const entry of readState(this.path).entries) this.entries.set(entry.hash, entry);
7237
+ }
7238
+ persist() {
7239
+ const entries = [...this.entries.values()].sort((a, b) => b.updatedAt - a.updatedAt);
7240
+ if (entries.length > MAX_MEDIA_PIPELINE_ENTRIES) entries.splice(MAX_MEDIA_PIPELINE_ENTRIES);
7241
+ this.entries.clear();
7242
+ for (const entry of entries) this.entries.set(entry.hash, entry);
7243
+ writeState(this.path, { version: 1, entries });
7244
+ }
7245
+ async removePath(removeMedia, path) {
7246
+ if (!removeMedia || !path) return false;
7247
+ try {
7248
+ await removeMedia(path);
7249
+ return true;
7250
+ } catch {
7251
+ return false;
7252
+ }
7253
+ }
7254
+ copy(row) {
7255
+ return { ...row, stages: [...row.stages], bindings: row.bindings.map((binding) => ({ ...binding })) };
7256
+ }
7257
+ get(hash) {
7258
+ const row = this.entries.get(hash);
7259
+ return row ? this.copy(row) : void 0;
7260
+ }
7261
+ getByEventId(eventId) {
7262
+ const row = [...this.entries.values()].find((entry) => entry.bindings.some((binding) => binding.eventId === eventId));
7263
+ return row ? this.copy(row) : void 0;
7264
+ }
7265
+ list() {
7266
+ return [...this.entries.values()].map((row) => this.copy(row));
7267
+ }
7268
+ async cleanup(options) {
7269
+ const cutoff = (options.now ?? Date.now()) - Math.max(0, options.olderThanMs);
7270
+ const expired = [...this.entries.values()].filter((entry) => entry.updatedAt < cutoff && (entry.originalPath !== void 0 || entry.previewPath !== void 0));
7271
+ let changed = 0;
7272
+ for (const entry of expired) {
7273
+ const originalRemoved = await this.removePath(options.remove, entry.originalPath);
7274
+ const previewRemoved = await this.removePath(options.remove, entry.previewPath);
7275
+ if (originalRemoved) entry.originalPath = void 0;
7276
+ if (previewRemoved) entry.previewPath = void 0;
7277
+ if (originalRemoved || previewRemoved) {
7278
+ entry.updatedAt = options.now ?? Date.now();
7279
+ changed += 1;
7280
+ }
7281
+ }
7282
+ if (changed > 0) this.persist();
7283
+ return changed;
7284
+ }
7285
+ async process(result, binding, saveOriginal, savePreview, removeMedia, now = Date.now()) {
7286
+ const validated = await validateInboundMedia(result);
7287
+ if (!validated.ok) return validated;
7288
+ const { buffer, mime } = validated.value;
7289
+ const hash = createHash2("sha256").update(buffer).digest("hex");
7290
+ const existing = this.entries.get(hash);
7291
+ const observedBinding = { ...binding, observedAt: now };
7292
+ if (existing?.status === "processed" && existing.originalPath) {
7293
+ if (!existing.bindings.some((item) => item.eventId === binding.eventId)) {
7294
+ existing.bindings.push(observedBinding);
7295
+ if (existing.bindings.length > 1e3) existing.bindings.shift();
7296
+ existing.updatedAt = now;
7297
+ this.persist();
7298
+ }
7299
+ return { ok: true, hash, mime: existing.mime, originalPath: existing.originalPath, previewPath: existing.previewPath, record: this.copy(existing) };
7300
+ }
7301
+ const record = {
7302
+ hash,
7303
+ status: "failed",
7304
+ type: result.type,
7305
+ mime,
7306
+ stages: ["validated", "deduplicated"],
7307
+ extraction: result.type === "image" || result.type === "file" ? "not_configured" : "not_applicable",
7308
+ bindings: [...(existing?.bindings ?? []).filter((item) => item.eventId !== binding.eventId), observedBinding].slice(-1e3),
7309
+ createdAt: existing?.createdAt ?? now,
7310
+ updatedAt: now
7311
+ };
7312
+ const original = await saveOriginal(buffer, mime, safeFilename(result.filename));
7313
+ if (!original?.path?.trim()) {
7314
+ record.errorCode = "MEDIA_SAVE_FAILED";
7315
+ this.entries.set(hash, record);
7316
+ this.persist();
7317
+ return { ok: false, code: "MEDIA_SAVE_FAILED" };
7318
+ }
7319
+ record.originalPath = original.path;
7320
+ record.stages.push("original_saved");
7321
+ if (result.type === "image") {
7322
+ if (!savePreview) {
7323
+ if (await this.removePath(removeMedia, record.originalPath)) record.originalPath = void 0;
7324
+ record.errorCode = "MEDIA_PREVIEW_FAILED";
7325
+ record.stages.push("preview_failed");
7326
+ this.entries.set(hash, record);
7327
+ this.persist();
7328
+ return { ok: false, code: "MEDIA_PREVIEW_FAILED" };
7329
+ }
7330
+ try {
7331
+ 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();
7332
+ const savedPreview = await savePreview(preview, "image/jpeg", previewFilename(result.filename));
7333
+ if (!savedPreview?.path?.trim()) throw new Error("preview save returned no path");
7334
+ record.previewPath = savedPreview.path;
7335
+ record.stages.push("preview_generated");
7336
+ } catch {
7337
+ if (await this.removePath(removeMedia, record.originalPath)) record.originalPath = void 0;
7338
+ if (await this.removePath(removeMedia, record.previewPath)) record.previewPath = void 0;
7339
+ record.errorCode = "MEDIA_PREVIEW_FAILED";
7340
+ record.stages.push("preview_failed");
7341
+ this.entries.set(hash, record);
7342
+ this.persist();
7343
+ return { ok: false, code: "MEDIA_PREVIEW_FAILED" };
7344
+ }
7345
+ }
7346
+ record.stages.push("extraction_skipped");
7347
+ record.status = "processed";
7348
+ record.updatedAt = now;
7349
+ this.entries.set(hash, record);
7350
+ this.persist();
7351
+ return { ok: true, hash, mime, originalPath: original.path, previewPath: record.previewPath, record: this.copy(record) };
7352
+ }
7353
+ };
7354
+ function loadMediaPipeline(accountId, stateDir) {
7355
+ return new MediaPipeline(accountId, stateDir);
7356
+ }
7357
+
7358
+ // src/newsapp.ts
7359
+ var NEWSAPP_USERNAME = "newsapp";
7360
+ var MAX_NEWSAPP_PREVIEW_CHARS = 4e3;
7361
+ function isNewsappChat(chat) {
7362
+ return (chat.username || chat.id) === NEWSAPP_USERNAME;
7363
+ }
7364
+ var URL_RE = /https?:\/\/[^\s<>,。;!?)】》]+/gi;
7365
+ function extractNewsappUrls(text) {
7366
+ return [...new Set(text.match(URL_RE) ?? [])].map(
7367
+ (url) => url.replace(/[),.;!?,。;!?)】》]+$/u, "")
7368
+ );
7369
+ }
7370
+ function newsappFallbackMessage(chat) {
7371
+ const rawPreview = chat.lastMessagePreview?.trim();
7372
+ const preview = rawPreview?.slice(0, MAX_NEWSAPP_PREVIEW_CHARS);
7373
+ const localId = chat.lastMsgLocalId;
7374
+ if (!preview || typeof localId !== "number" || !Number.isSafeInteger(localId) || localId <= 0) return null;
7375
+ const urls = extractNewsappUrls(preview);
7376
+ const urlBlock = urls.length > 0 ? `
7377
+ Links:
7378
+ ${urls.join("\n")}` : "";
7379
+ return {
7380
+ localId,
7381
+ serverId: localId,
7382
+ chatId: chat.username || chat.id,
7383
+ type: 1,
7384
+ content: `[Tencent News]
7385
+ ${preview}${urlBlock}`,
7386
+ timestamp: chat.lastActivityAt || `1970-01-01T00:00:${String(localId % 60).padStart(2, "0")}.000Z`,
7387
+ sender: NEWSAPP_USERNAME,
7388
+ senderName: "\u817E\u8BAF\u65B0\u95FB"
7389
+ };
7390
+ }
7391
+ function shouldBypassNewsappAuthorization(chat) {
7392
+ return isNewsappChat(chat);
6662
7393
  }
6663
7394
 
6664
7395
  // src/monitor.ts
@@ -6695,6 +7426,15 @@ async function startWeChatMonitor(opts) {
6695
7426
  const startupBaselines = /* @__PURE__ */ new Map();
6696
7427
  const chatScanState = { initialScanComplete: false };
6697
7428
  const pendingMessageScans = loadPendingResetRetries(account.accountId);
7429
+ const inboundLedger = loadInboundEventLedger(account.accountId);
7430
+ const mediaPipeline = loadMediaPipeline(account.accountId);
7431
+ const cleanupMedia = async () => {
7432
+ await mediaPipeline.cleanup({
7433
+ olderThanMs: MEDIA_RETENTION_MS,
7434
+ remove: async (path) => unlink(path)
7435
+ }).catch((err) => log?.error?.(`[wechat:${account.accountId}] Media cleanup failed: ${err}`));
7436
+ };
7437
+ let lastMediaCleanup = 0;
6698
7438
  for (const [chatId, pending] of pendingMessageScans) {
6699
7439
  const cursor = pending.messages.reduce(
6700
7440
  (max, message) => Math.max(max, message.localId),
@@ -6708,6 +7448,7 @@ async function startWeChatMonitor(opts) {
6708
7448
  persist: () => persistPendingResetRetries(account.accountId, pendingMessageScans)
6709
7449
  };
6710
7450
  trimPendingResetRetries(retryState);
7451
+ const newsappHandled = /* @__PURE__ */ new Map();
6711
7452
  const groupHistory = /* @__PURE__ */ new Map();
6712
7453
  const GROUP_HISTORY_LIMIT = 50;
6713
7454
  let lastAuthCheck = 0;
@@ -6724,6 +7465,10 @@ async function startWeChatMonitor(opts) {
6724
7465
  try {
6725
7466
  const cfg = getWeChatRuntime().config.current();
6726
7467
  const now = Date.now();
7468
+ if (now - lastMediaCleanup >= MEDIA_RETENTION_MS / 4) {
7469
+ lastMediaCleanup = now;
7470
+ await cleanupMedia();
7471
+ }
6727
7472
  if (now - lastAuthCheck >= account.authPollIntervalMs) {
6728
7473
  lastAuthCheck = now;
6729
7474
  try {
@@ -6810,9 +7555,15 @@ async function startWeChatMonitor(opts) {
6810
7555
  startupBaselines.set(chatId, { localId: 0 });
6811
7556
  }
6812
7557
  }
6813
- const chatsToProcess = monitorChatsToProcess(chats, retryState);
7558
+ const chatsToProcess = monitorChatsToProcess(
7559
+ chats,
7560
+ retryState,
7561
+ Date.now(),
7562
+ (chat) => !isNewsappChat(chat) || newsappHandled.get(chat.username ?? chat.id) !== `${chat.lastMsgLocalId ?? ""}:${chat.lastMessagePreview?.trim() ?? ""}`
7563
+ );
6814
7564
  for (const chatId of chatsToProcess.keys()) {
6815
- if (isOfficialAccount(chatId)) chatsToProcess.delete(chatId);
7565
+ if (isOfficialAccount(chatId) && chatId !== "newsapp")
7566
+ chatsToProcess.delete(chatId);
6816
7567
  }
6817
7568
  if (unreadChats.length > 0) {
6818
7569
  log?.info?.(
@@ -6832,6 +7583,8 @@ async function startWeChatMonitor(opts) {
6832
7583
  startupBaselines,
6833
7584
  pendingMessageScans,
6834
7585
  retryState.persist,
7586
+ inboundLedger,
7587
+ mediaPipeline,
6835
7588
  account,
6836
7589
  cfg,
6837
7590
  log,
@@ -6851,6 +7604,12 @@ async function startWeChatMonitor(opts) {
6851
7604
  if (result === "deferred") {
6852
7605
  deferredThisTick += 1;
6853
7606
  }
7607
+ if (isNewsappChat(chat) && result !== "deferred") {
7608
+ newsappHandled.set(
7609
+ chat.username ?? chat.id,
7610
+ `${chat.lastMsgLocalId ?? ""}:${chat.lastMessagePreview?.trim() ?? ""}`
7611
+ );
7612
+ }
6854
7613
  }
6855
7614
  }
6856
7615
  for (const chat of chats) {
@@ -6872,6 +7631,8 @@ async function startWeChatMonitor(opts) {
6872
7631
  startupBaselines,
6873
7632
  pendingMessageScans,
6874
7633
  retryState.persist,
7634
+ inboundLedger,
7635
+ mediaPipeline,
6875
7636
  account,
6876
7637
  cfg,
6877
7638
  log,
@@ -6909,13 +7670,14 @@ async function startWeChatMonitor(opts) {
6909
7670
  }
6910
7671
  await sleep(account.pollIntervalMs, abortSignal);
6911
7672
  }
7673
+ await cleanupMedia();
6912
7674
  setStatus({
6913
7675
  accountId: account.accountId,
6914
7676
  running: false,
6915
7677
  connected: false
6916
7678
  });
6917
7679
  }
6918
- async function prepareMessage(client, msg, chatId, chat, liveAccount, policy, log) {
7680
+ async function prepareMessage(client, mediaPipeline, msg, chatId, chat, liveAccount, policy, log) {
6919
7681
  const core = getWeChatRuntime();
6920
7682
  if (msg.isSelf) {
6921
7683
  log?.info?.(`[wechat:${liveAccount.accountId}] Skipping self-sent msg ${msg.localId}`);
@@ -6925,7 +7687,7 @@ async function prepareMessage(client, msg, chatId, chat, liveAccount, policy, lo
6925
7687
  const senderId = msg.sender ?? chatId;
6926
7688
  const senderName = msg.senderName ?? msg.sender ?? chat.name;
6927
7689
  const wasMentioned = isGroup && msg.isMentioned === true;
6928
- const access = resolveWeChatInboundAccessDecision({
7690
+ const access = shouldBypassNewsappAuthorization(chat) ? { allowed: true, reason: "newsapp-system-feed" } : resolveWeChatInboundAccessDecision({
6929
7691
  isGroup,
6930
7692
  senderId,
6931
7693
  policy
@@ -6938,6 +7700,10 @@ async function prepareMessage(client, msg, chatId, chat, liveAccount, policy, lo
6938
7700
  }
6939
7701
  let mediaPath;
6940
7702
  let mediaMime;
7703
+ let mediaHash;
7704
+ let mediaPreviewPath;
7705
+ let mediaSource;
7706
+ let mediaErrorCode;
6941
7707
  let hasMedia = false;
6942
7708
  const baseType = msg.type & 2147483647;
6943
7709
  const mayHaveMedia = MEDIA_TYPES.has(baseType) || baseType === 49;
@@ -6947,23 +7713,33 @@ async function prepareMessage(client, msg, chatId, chat, liveAccount, policy, lo
6947
7713
  const result = await pollMedia(client, chatId, msg.localId, log);
6948
7714
  if (result && result.data && result.type !== "unsupported") {
6949
7715
  hasMedia = true;
7716
+ mediaSource = result.source;
6950
7717
  log?.info?.(`[wechat:media] inbound media received type=${result.type} format=${result.format}`);
6951
- const saved = await saveValidatedInboundMedia(
7718
+ const saved = await mediaPipeline.process(
6952
7719
  result,
6953
- async (buffer, mime, filename) => core.channel.media.saveMediaBuffer(buffer, mime, "inbound", void 0, filename)
7720
+ { eventId: inboundEventId(liveAccount.accountId, chatId, msg), chatId, localId: msg.localId },
7721
+ async (buffer, mime, filename) => core.channel.media.saveMediaBuffer(buffer, mime, "inbound", void 0, filename),
7722
+ async (buffer, mime, filename) => core.channel.media.saveMediaBuffer(buffer, mime, "inbound", void 0, filename),
7723
+ async (path) => unlink(path)
6954
7724
  );
6955
7725
  if (!saved.ok) {
7726
+ mediaErrorCode = saved.code;
6956
7727
  log?.error?.(`[wechat:media] inbound media rejected code=${saved.code}`);
6957
7728
  } else {
6958
7729
  mediaMime = saved.mime;
6959
- mediaPath = saved.path;
6960
- log?.info?.("[wechat:media] inbound media saved");
7730
+ mediaPath = saved.originalPath;
7731
+ mediaHash = saved.hash;
7732
+ mediaPreviewPath = saved.previewPath;
7733
+ log?.info?.(`[wechat:media] inbound media saved hash=${saved.hash}${saved.previewPath ? " preview=present" : ""}`);
6961
7734
  }
6962
- } else if (MEDIA_TYPES.has(baseType)) {
7735
+ } else if (result?.errorCode || result && result.type !== "unsupported" || MEDIA_TYPES.has(baseType)) {
6963
7736
  hasMedia = true;
6964
- log?.info?.("[wechat:media] inbound media unavailable after retries");
7737
+ mediaErrorCode = result?.errorCode ?? "MEDIA_DOWNLOAD_UNAVAILABLE";
7738
+ log?.info?.(`[wechat:media] inbound media unavailable code=${mediaErrorCode}`);
6965
7739
  }
6966
7740
  } catch (err) {
7741
+ hasMedia = true;
7742
+ mediaErrorCode = "MEDIA_DOWNLOAD_FAILED";
6967
7743
  log?.error?.("[wechat:media] inbound media failed code=MEDIA_DOWNLOAD_FAILED");
6968
7744
  }
6969
7745
  }
@@ -6981,12 +7757,22 @@ async function prepareMessage(client, msg, chatId, chat, liveAccount, policy, lo
6981
7757
  } else if (!mediaMime.startsWith("image/") && !mediaMime.startsWith("audio/")) {
6982
7758
  rawBody = `[File: ${rawBody}]`;
6983
7759
  }
7760
+ } else if (mediaErrorCode && baseType === 49) {
7761
+ const text = msg.content || "File attachment";
7762
+ rawBody = `${text}
7763
+ [Attachment unavailable: ${mediaErrorCode}]`;
7764
+ }
7765
+ if (mediaSource === "thumbnail") {
7766
+ rawBody = rawBody ? `${rawBody}
7767
+ [Image source: thumbnail]` : "[Image source: thumbnail]";
6984
7768
  }
6985
7769
  if (msg.reply) {
6986
7770
  const replySender = msg.reply.sender ?? "unknown sender";
6987
7771
  const quotedBody = msg.reply.content.length > 50 ? msg.reply.content.slice(0, 50) + "..." : msg.reply.content;
7772
+ const replyMedia = msg.reply.mediaErrorCode ? `
7773
+ [Quoted attachment unavailable: ${msg.reply.mediaErrorCode}]` : "";
6988
7774
  const replyBlock = `[Replying to ${replySender}]
6989
- ${quotedBody}
7775
+ ${quotedBody}${replyMedia}
6990
7776
  [/Replying]`;
6991
7777
  rawBody = rawBody ? `${rawBody}
6992
7778
 
@@ -7001,6 +7787,8 @@ ${replyBlock}` : replyBlock;
7001
7787
  }),
7002
7788
  mediaPath,
7003
7789
  mediaMime,
7790
+ mediaHash,
7791
+ mediaPreviewPath,
7004
7792
  senderName,
7005
7793
  senderId,
7006
7794
  isGroup,
@@ -7163,7 +7951,19 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
7163
7951
  CommandAuthorized: commandAuthorized,
7164
7952
  OriginatingChannel: OPENCLAW_CHANNEL_ID,
7165
7953
  OriginatingTo: `${OPENCLAW_CHANNEL_ID}:${chatId}`,
7166
- ...mediaPath ? { MediaPath: mediaPath, MediaUrl: mediaPath, MediaType: mediaMime } : {},
7954
+ ...mediaPath ? {
7955
+ // The runtime media consumers read the plural attachment contract. Keep
7956
+ // the legacy singular aliases for older runtimes, but always populate
7957
+ // both forms for downloaded inbound media.
7958
+ MediaPath: mediaPath,
7959
+ MediaUrl: mediaPath,
7960
+ MediaType: mediaMime,
7961
+ MediaPaths: [mediaPath],
7962
+ MediaUrls: [mediaPath],
7963
+ MediaTypes: mediaMime ? [mediaMime] : [],
7964
+ ...mediaMsg?.mediaHash ? { MediaHash: mediaMsg.mediaHash } : {},
7965
+ ...mediaMsg?.mediaPreviewPath ? { MediaPreviewPath: mediaMsg.mediaPreviewPath } : {}
7966
+ } : {},
7167
7967
  ...msg.reply ? {
7168
7968
  ReplyToBody: msg.reply.content.length > 50 ? msg.reply.content.slice(0, 50) + "..." : msg.reply.content,
7169
7969
  ReplyToSender: msg.reply.sender
@@ -7201,7 +8001,7 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
7201
8001
  payload.text ?? "",
7202
8002
  tableMode
7203
8003
  );
7204
- if (mediaList.length > 0) {
8004
+ if (mediaList.length > 0 && !isNewsappChat(chat)) {
7205
8005
  const media = [];
7206
8006
  for (const mediaUrl of mediaList) {
7207
8007
  const fsmod = await import("fs/promises");
@@ -7242,7 +8042,7 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
7242
8042
  inboundChars: lastMsg.rawBody.length,
7243
8043
  source: "openclaw"
7244
8044
  });
7245
- } else if (text) {
8045
+ } else if (text && !isNewsappChat(chat)) {
7246
8046
  const result = await client.sendMessage({
7247
8047
  chatId,
7248
8048
  text,
@@ -7289,7 +8089,7 @@ function bufferGroupHistory(groupHistory, chatId, pm, limit) {
7289
8089
  if (first) groupHistory.delete(first);
7290
8090
  }
7291
8091
  }
7292
- async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHandled, startupBaselines, pendingMessageScans, persistRetryState, account, cfg, log, skipOpen, groupHistory, groupHistoryLimit, catchup) {
8092
+ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHandled, startupBaselines, pendingMessageScans, persistRetryState, inboundLedger, mediaPipeline, account, cfg, log, skipOpen, groupHistory, groupHistoryLimit, catchup) {
7293
8093
  const core = getWeChatRuntime();
7294
8094
  const liveAccount = resolveWeChatAccount(cfg, account.accountId) ?? account;
7295
8095
  const chatId = chat.username ?? chat.id;
@@ -7374,12 +8174,22 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
7374
8174
  log?.info?.(
7375
8175
  `[wechat:${liveAccount.accountId}] ${chatId}: fetched ${messages.length} msgs, firstPoll=${firstPoll}, prevLastSeen=${prevLastSeen}, unreadCount=${chat.unreadCount}`
7376
8176
  );
8177
+ if (messages.length === 0 && isNewsappChat(chat)) {
8178
+ const fallback = newsappFallbackMessage(chat);
8179
+ if (fallback) messages = [fallback];
8180
+ }
7377
8181
  if (messages.length === 0) return "skipped";
7378
8182
  const enrichedBaseline = enrichStartupBaselineFromMessages(startupBaseline, messages);
7379
8183
  if (enrichedBaseline) {
7380
8184
  startupBaselines.set(chatId, enrichedBaseline);
7381
8185
  }
7382
- const selected = selectCursorMessages(
8186
+ const selected = isNewsappChat(chat) ? {
8187
+ firstPoll,
8188
+ prevLastSeen,
8189
+ messages,
8190
+ seedLastSeen: void 0,
8191
+ generationReset: false
8192
+ } : selectCursorMessages(
7383
8193
  chatId,
7384
8194
  chat,
7385
8195
  messages,
@@ -7430,17 +8240,17 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
7430
8240
  chatId
7431
8241
  );
7432
8242
  }
7433
- if (chat.unreadCount > 0) {
8243
+ if (chat.unreadCount > 0 && !isNewsappChat(chat)) {
7434
8244
  await openChatIfNeeded();
7435
8245
  }
7436
8246
  return "skipped";
7437
8247
  }
7438
- await openChatIfNeeded();
8248
+ if (!isNewsappChat(chat)) await openChatIfNeeded();
7439
8249
  const catchUpLimits = {
7440
8250
  maxMessages: liveAccount.catchUpMaxMessages,
7441
8251
  maxAgeMs: liveAccount.catchUpMaxAgeMs
7442
8252
  };
7443
- const isRecovery = firstPoll && startupBaseline === void 0 || skipOpen === true && isCatchUpBatch(newMessages, catchUpLimits);
8253
+ const isRecovery = !isNewsappChat(chat) && (firstPoll && startupBaseline === void 0 || skipOpen === true && isCatchUpBatch(newMessages, catchUpLimits));
7444
8254
  if (isRecovery) {
7445
8255
  const selection = selectCatchUpMessages(
7446
8256
  newMessages,
@@ -7473,13 +8283,20 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
7473
8283
  `[wechat:${liveAccount.accountId}] ${chatId}: ${newMessages.length} new msg(s) to process`
7474
8284
  );
7475
8285
  const processed = [];
8286
+ const eventIds = [];
7476
8287
  for (const msg of newMessages) {
8288
+ const eventId = inboundLedger.ensure(account.accountId, chatId, msg);
8289
+ if (!inboundLedger.shouldProcess(eventId)) continue;
8290
+ inboundLedger.markProcessing(eventId);
8291
+ eventIds.push(eventId);
7477
8292
  log?.info?.(
7478
8293
  `[wechat:${liveAccount.accountId}] Processing msg ${msg.localId}: type=${msg.type}, sender=${msg.sender}, isSelf=${msg.isSelf}, content=${(msg.content || "").slice(0, 50)}`
7479
8294
  );
7480
- const pm = await prepareMessage(client, msg, chatId, chat, liveAccount, policy, log);
8295
+ const pm = await prepareMessage(client, mediaPipeline, msg, chatId, chat, liveAccount, policy, log);
7481
8296
  if (pm) {
7482
8297
  processed.push(pm);
8298
+ } else {
8299
+ inboundLedger.markProcessed(eventId, "filtered");
7483
8300
  }
7484
8301
  }
7485
8302
  const isGroup = chatId.includes("@chatroom");
@@ -7496,6 +8313,7 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
7496
8313
  log?.info?.(`[wechat:${liveAccount.accountId}] Buffered ${processed.length} msg(s) for group history in ${chatId}`);
7497
8314
  const maxId2 = Math.max(...newMessages.map((m) => m.localId));
7498
8315
  advanceLastSeen(newMessages.filter((message) => message.localId <= maxId2));
8316
+ for (const eventId of eventIds) inboundLedger.markProcessed(eventId, "buffered");
7499
8317
  return "skipped";
7500
8318
  }
7501
8319
  if (hasMention) {
@@ -7562,6 +8380,7 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
7562
8380
  );
7563
8381
  const maxId2 = Math.max(...newMessages.map((m) => m.localId));
7564
8382
  advanceLastSeen(newMessages.filter((message) => message.localId <= maxId2));
8383
+ for (const eventId of eventIds) inboundLedger.markProcessed(eventId, "stale");
7565
8384
  return "skipped";
7566
8385
  }
7567
8386
  const fold = shouldFoldSegments(catchup?.isReconnect ?? false) && !hasControlCommandInWindow;
@@ -7571,6 +8390,13 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
7571
8390
  );
7572
8391
  let allDispatched = true;
7573
8392
  const successfulSegmentLastLocalIds = [];
8393
+ const segmentEventIds = (segment) => {
8394
+ const keys = new Set(segment.map((pm) => pm.msg.localId));
8395
+ return eventIds.filter((eventId) => {
8396
+ const record = inboundLedger.get(eventId);
8397
+ return record !== void 0 && keys.has(record.localId);
8398
+ });
8399
+ };
7574
8400
  for (let i = 0; i < segments.length; i++) {
7575
8401
  const remaining = segments.length - i - 1;
7576
8402
  const dispatched = await dispatchSegment(
@@ -7588,12 +8414,19 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
7588
8414
  );
7589
8415
  if (!dispatched) {
7590
8416
  allDispatched = false;
8417
+ inboundLedger.markFailedBatch(segmentEventIds(segments[i]), "DISPATCH_FAILED");
8418
+ const remainingIds = segments.slice(i + 1).flatMap(segmentEventIds);
8419
+ inboundLedger.markFailedBatch(remainingIds, "DISPATCH_NOT_ATTEMPTED");
7591
8420
  break;
7592
8421
  }
8422
+ inboundLedger.markProcessedBatch(segmentEventIds(segments[i]), "dispatched");
7593
8423
  successfulSegmentLastLocalIds.push(
7594
8424
  Math.max(...segments[i].map((pm) => pm.msg.localId))
7595
8425
  );
7596
8426
  }
8427
+ if (allDispatched) {
8428
+ for (const eventId of eventIds) inboundLedger.markProcessed(eventId, "dispatched");
8429
+ }
7597
8430
  if (clearBufferedHistory && allDispatched && groupHistory) {
7598
8431
  groupHistory.set(chatId, []);
7599
8432
  }
@@ -7854,6 +8687,14 @@ function buildOpenClawConfirmedSend(params) {
7854
8687
  };
7855
8688
  }
7856
8689
 
8690
+ // src/group-members-consent.ts
8691
+ function hasExplicitGroupMemberConsent(params) {
8692
+ return params.confirmed === true;
8693
+ }
8694
+ function groupMemberErrorCode(error) {
8695
+ return error instanceof WeChatHttpError && error.errorCode ? error.errorCode : "GROUP_MEMBERS_FAILED";
8696
+ }
8697
+
7857
8698
  // src/agent-tools.ts
7858
8699
  function createWeChatConfirmedSendTool(account) {
7859
8700
  const client = new WeChatClient({
@@ -7901,6 +8742,52 @@ function createWeChatConfirmedSendTool(account) {
7901
8742
  }
7902
8743
  };
7903
8744
  }
8745
+ function createWeChatGroupMembersTool(account) {
8746
+ const client = new WeChatClient({ baseUrl: account.serverUrl, token: account.token });
8747
+ return {
8748
+ label: "WeChat Group Members",
8749
+ name: "wechat_group_members",
8750
+ 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.",
8751
+ parameters: {
8752
+ type: "object",
8753
+ additionalProperties: false,
8754
+ properties: {
8755
+ groupId: { type: "string", description: "Stable group id ending in @chatroom" },
8756
+ limit: { type: "number", minimum: 1, maximum: 50 },
8757
+ cursor: { type: "string" },
8758
+ confirmed: { type: "boolean", description: "Must be true after the user explicitly requests this group's personal member data." }
8759
+ },
8760
+ required: ["groupId", "confirmed"]
8761
+ },
8762
+ execute: async (_toolCallId, params) => {
8763
+ const args = params;
8764
+ const groupId = String(args.groupId ?? "").trim();
8765
+ if (!hasExplicitGroupMemberConsent(args)) {
8766
+ return {
8767
+ content: [{ type: "text", text: "Explicit user confirmation is required before reading group member personal data." }],
8768
+ details: { success: false, errorCode: "EXPLICIT_CONFIRMATION_REQUIRED" }
8769
+ };
8770
+ }
8771
+ const limit = typeof args.limit === "number" ? Math.trunc(args.limit) : 25;
8772
+ try {
8773
+ const page = await client.listGroupMembersPage(
8774
+ groupId,
8775
+ Math.min(50, Math.max(1, limit)),
8776
+ typeof args.cursor === "string" ? args.cursor : void 0
8777
+ );
8778
+ return {
8779
+ content: [{ type: "text", text: `Retrieved ${page.items.length} group member(s).` }],
8780
+ details: page
8781
+ };
8782
+ } catch (error) {
8783
+ return {
8784
+ content: [{ type: "text", text: "Group member lookup failed." }],
8785
+ details: { error: true, code: groupMemberErrorCode(error) }
8786
+ };
8787
+ }
8788
+ }
8789
+ };
8790
+ }
7904
8791
  function createWeChatLoginTool(account) {
7905
8792
  const client = new WeChatClient({
7906
8793
  baseUrl: account.serverUrl,
@@ -8434,7 +9321,11 @@ Error: ${event.message}`);
8434
9321
  agentTools: (({ cfg }) => {
8435
9322
  const account = resolveWeChatAccount(cfg);
8436
9323
  if (!account?.serverUrl) return [];
8437
- return [createWeChatLoginTool(account), createWeChatConfirmedSendTool(account)];
9324
+ return [
9325
+ createWeChatLoginTool(account),
9326
+ createWeChatConfirmedSendTool(account),
9327
+ createWeChatGroupMembersTool(account)
9328
+ ];
8438
9329
  }),
8439
9330
  // ---- Directory adapter ----
8440
9331
  directory: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyan-du/agent-wechat-openclaw",
3
- "version": "0.13.3",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",