@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.
- package/dist/index.js +1090 -62
- 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-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
6655
|
-
if (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
|
-
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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(
|
|
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
|
|
7840
|
+
const saved = await mediaPipeline.process(
|
|
6952
7841
|
result,
|
|
6953
|
-
|
|
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.
|
|
6960
|
-
|
|
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
|
-
|
|
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 ? {
|
|
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)
|
|
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 =
|
|
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 [
|
|
9461
|
+
return [
|
|
9462
|
+
createWeChatLoginTool(account),
|
|
9463
|
+
createWeChatConfirmedSendTool(account),
|
|
9464
|
+
createWeChatGroupMembersTool(account)
|
|
9465
|
+
];
|
|
8438
9466
|
}),
|
|
8439
9467
|
// ---- Directory adapter ----
|
|
8440
9468
|
directory: {
|