@kyan-du/agent-wechat-openclaw 0.13.2 → 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.
- package/dist/index.js +940 -49
- 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),
|
|
@@ -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-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
6655
|
-
if (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
|
-
|
|
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(
|
|
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)
|
|
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
|
|
7718
|
+
const saved = await mediaPipeline.process(
|
|
6952
7719
|
result,
|
|
6953
|
-
|
|
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.
|
|
6960
|
-
|
|
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
|
-
|
|
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 ? {
|
|
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 =
|
|
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 [
|
|
9324
|
+
return [
|
|
9325
|
+
createWeChatLoginTool(account),
|
|
9326
|
+
createWeChatConfirmedSendTool(account),
|
|
9327
|
+
createWeChatGroupMembersTool(account)
|
|
9328
|
+
];
|
|
8438
9329
|
}),
|
|
8439
9330
|
// ---- Directory adapter ----
|
|
8440
9331
|
directory: {
|