Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

This project follows the [Keep a Changelog](https://keepachangelog.com/) format.

## [Unreleased]

### Added

- **Quote reconstruction for newer WeChat clients:** Losslessly parse message IDs and resolve ID-only `svr_id` text and partial quotes through a per-account, per-conversation SQLite side store. Images, video, voice, and attachments are written directly into plugin-owned OpenClaw managed storage, using one retained file with time, count, byte-budget, and single-file eviction limits. Restored attachments include tool-access hints so agents can locate and read the file when automatic extraction fails.
- **Compatible degradation:** Disable quote caching when `node:sqlite` is unavailable or `quoteCache.enabled=false`, with no in-memory fallback. Cache failures never interrupt normal message delivery.

## [2.4.7] - 2026-08-31

### Fixed
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

本项目遵循 [Keep a Changelog](https://keepachangelog.com/) 格式。

## [未发布]

### 新增

- **新版微信引用消息还原:** 对消息 ID 做无损解析,并用按账号、会话隔离的 SQLite 旁路存储还原只携带 `svr_id` 的文本引用和局部引用。图片、视频、语音及附件首次直接写入插件专属的 OpenClaw 受管目录,以单份文件支持按时间、条数、空间和单文件大小淘汰;还原引用附件时会提供工具访问提示,便于 agent 在自动抽取失败后定位并读取文件。
- **兼容降级:** `node:sqlite` 不可用或 `quoteCache.enabled=false` 时关闭引用缓存,不启用内存替代方案;缓存异常不会影响正常消息收发。

## [2.4.7] - 2026-08-31

### 修复
Expand Down
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,78 @@ authentication or routing. All registered agents on this plugin instance
currently share the same `botAgent` declaration; per-agent overrides may be
added in a future version if needed.

## Local quote cache

Newer WeChat clients may send only a server message ID for a quoted message. The
plugin therefore stores text and media metadata in SQLite and writes images,
video, voice, and attachments directly into plugin-owned OpenClaw managed
storage, so current delivery and later quotes reuse one file. When a quoted
attachment is restored, the agent also receives its original filename, managed
source path, and workspace `media/inbound/` hint, allowing file/PDF tools to read
it if automatic extraction fails. Records are isolated by account and
conversation. By default, text is retained for 30 days with a limit of 10,000
messages per account; media is retained for 7 days with a 256 MiB per-account
budget and a 25 MiB single-file limit. Cleanup runs at startup, hourly, every 100
writes, whenever the media budget is exceeded, and when an account is deleted.

The quote cache is **enabled by default**. No configuration is required after
upgrading: this preserves the quoted-message experience that existed before
newer WeChat clients stopped including the quoted body. Configure it under
`channels.openclaw-weixin.quoteCache` only when you need different limits.

| Option | Default | Description |
| --- | ---: | --- |
| `enabled` | `true` | Enables local quote reconstruction. |
| `retentionDays` | `30` | Number of days to retain text and message metadata. |
| `maxMessagesPerAccount` | `10000` | Maximum message records retained per account. |
| `mediaRetentionDays` | `7` | Number of days to retain quoted media files. |
| `maxMediaBytesPerAccount` | `268435456` | Maximum retained media per account, in bytes (256 MiB). |
| `maxSingleMediaBytes` | `26214400` | Maximum size of one retained media file, in bytes (25 MiB). Larger files are still delivered normally but are not retained for later quote reconstruction. |

Example with custom limits while keeping the feature enabled:

```json
{
"channels": {
"openclaw-weixin": {
"quoteCache": {
"enabled": true,
"retentionDays": 14,
"maxMessagesPerAccount": 5000,
"mediaRetentionDays": 3,
"maxMediaBytesPerAccount": 134217728,
"maxSingleMediaBytes": 26214400
}
}
}
}
```

To disable local quote storage explicitly:

```json
{
"channels": {
"openclaw-weixin": {
"quoteCache": {
"enabled": false
}
}
}
}
```

Restart the gateway after changing the configuration:

```bash
openclaw gateway restart
```

If `node:sqlite` is unavailable or the database cannot be opened, the plugin
logs a warning and disables this feature automatically. It does not fall back to
an in-memory cache, and quote-cache failures do not interrupt normal message
delivery.

## Backend API Protocol

This plugin communicates with the backend gateway via HTTP JSON API. Developers integrating with their own backend need to implement the following interfaces.
Expand Down
65 changes: 65 additions & 0 deletions README.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,71 @@ openclaw config set session.dmScope per-account-channel-peer
已注册的 agent 共享同一个 `botAgent` 声明;如有需要按 agent 单独标识的场景,
可在后续版本扩展配置。

## 引用消息本地缓存

新版微信的引用消息可能只携带服务端消息 ID。插件默认使用 SQLite 保存文本和媒体元数据,
并将图片、视频、语音及附件首次直接写入插件专属的 OpenClaw 受管目录,让当前消息和后续引用
复用同一份文件。命中引用附件时,还会向 agent 提供原文件名、受管源路径及工作区
`media/inbound/` 提示;自动抽取失败时,agent 可调用文件/PDF 工具读取。缓存按账号和会话隔离;
文本默认保留 30 天且每账号最多 10,000 条,媒体默认保留 7 天、每账号最多 256 MiB、单文件
最多 25 MiB。淘汰在启动时、每小时、每写入 100 条及媒体超出空间上限时触发;删除账号时
会同步删除其引用缓存。

引用缓存**默认开启**,升级后无需增加任何配置。这样可以在新版微信客户端不再携带引用正文后,
继续保持此前的引用消息体验。只有需要调整缓存限制时,才需要配置
`channels.openclaw-weixin.quoteCache`。

| 配置项 | 默认值 | 说明 |
| --- | ---: | --- |
| `enabled` | `true` | 是否启用本地引用消息还原。 |
| `retentionDays` | `30` | 文本及消息元数据的保留天数。 |
| `maxMessagesPerAccount` | `10000` | 每个账号最多保留的消息记录数。 |
| `mediaRetentionDays` | `7` | 引用媒体文件的保留天数。 |
| `maxMediaBytesPerAccount` | `268435456` | 每个账号最多保留的媒体总大小,单位为字节(256 MiB)。 |
| `maxSingleMediaBytes` | `26214400` | 单个可保留媒体文件的最大大小,单位为字节(25 MiB)。超出后仍正常投递当前消息,但不会为后续引用保留文件。 |

保持功能开启并自定义限制的示例:

```json
{
"channels": {
"openclaw-weixin": {
"quoteCache": {
"enabled": true,
"retentionDays": 14,
"maxMessagesPerAccount": 5000,
"mediaRetentionDays": 3,
"maxMediaBytesPerAccount": 134217728,
"maxSingleMediaBytes": 26214400
}
}
}
}
```

如需明确关闭本地引用存储,只需配置:

```json
{
"channels": {
"openclaw-weixin": {
"quoteCache": {
"enabled": false
}
}
}
}
```

修改配置后重启 gateway:

```bash
openclaw gateway restart
```

如当前 Node.js 不提供 `node:sqlite`,或数据库无法打开,插件会记录警告并自动关闭此功能。
插件不会使用内存缓存降级,引用缓存异常也不会中断正常消息收发。

## 后端 API 协议

本插件通过 HTTP JSON API 与后端网关通信。二次开发者若需对接自有后端,需实现以下接口。
Expand Down
58 changes: 57 additions & 1 deletion src/api/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
sanitizeBotAgent,
readPackageJsonFromDir,
classifyFetchError,
parseWeixinApiJson,
} from "./api.js";

function mockResponse(body: object | string, status = 200, ok = true): Response {
Expand Down Expand Up @@ -187,7 +188,14 @@ describe("sendMessage", () => {
} as Response);
await expect(
sendMessage({ baseUrl: "https://api.example.com/", body: { msg: { to_user_id: "u" } } }),
).resolves.toBeUndefined();
).resolves.toEqual({});
});

it("returns a lossless server message ID", async () => {
mockFetch.mockResolvedValueOnce(mockResponse('{"ret":0,"message_id":18446744073709551615}'));
await expect(
sendMessage({ baseUrl: "https://api.example.com/", body: { msg: { to_user_id: "u" } } }),
).resolves.toMatchObject({ message_id: "18446744073709551615" });
});

it("throws on non-ok response", async () => {
Expand All @@ -196,6 +204,54 @@ describe("sendMessage", () => {
sendMessage({ baseUrl: "https://api.example.com/", body: { msg: {} } }),
).rejects.toThrow("sendMessage 403");
});

it("throws on a successful HTTP response with a non-zero API ret", async () => {
mockFetch.mockResolvedValueOnce(mockResponse({ ret: 1 }));
await expect(
sendMessage({ baseUrl: "https://api.example.com/", body: { msg: {} } }),
).rejects.toThrow("sendMessage ret=1 errmsg=(none)");
});
});

describe("parseWeixinApiJson", () => {
it("preserves all known uint64 message ID fields as strings", () => {
const parsed = parseWeixinApiJson<{
message_id: string;
nested: { msg_id: string; ref: { svr_id: string } };
}>(
'{"message_id":18446744073709551615,"nested":{"msg_id" : 9007199254740993,"ref":{"svr_id": 123}}}',
);
expect(parsed).toEqual({
message_id: "18446744073709551615",
nested: { msg_id: "9007199254740993", ref: { svr_id: "123" } },
});
});

it("does not rewrite matching text inside JSON strings", () => {
const raw = '{"text":"\\\"message_id\\\":18446744073709551615","message_id":"7"}';
expect(parseWeixinApiJson<{ text: string; message_id: string }>(raw)).toEqual({
text: '"message_id":18446744073709551615',
message_id: "7",
});
});

it("leaves non-ID keys and non-numeric ID values unchanged", () => {
expect(
parseWeixinApiJson<unknown>(
'{ "other": 9007199254740993, "message_id": null, "msg_id": "already-string" }',
),
).toEqual({
other: 9007199254740992,
message_id: null,
msg_id: "already-string",
});
});

it("handles negative numeric IDs without losing precision", () => {
expect(parseWeixinApiJson<{ svr_id: string }>('{"svr_id" : -9007199254740993}')).toEqual({
svr_id: "-9007199254740993",
});
});
});

describe("getConfig", () => {
Expand Down
70 changes: 66 additions & 4 deletions src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ export async function getUpdates(
label: "getUpdates",
abortSignal: params.abortSignal,
});
const resp: GetUpdatesResp = JSON.parse(rawText);
const resp = parseWeixinApiJson<GetUpdatesResp>(rawText);
return resp;
} catch (err) {
// Long-poll timeout or external abort are both normal control-flow exits.
Expand Down Expand Up @@ -515,10 +515,71 @@ export async function getUploadUrl(
return resp;
}

/** Send a single message downstream. */
const LOSSLESS_ID_FIELDS = new Set(["message_id", "msg_id", "svr_id"]);

/**
* Quote uint64 message identifiers before JSON.parse sees them. This scanner
* only rewrites actual object properties, never matching text inside JSON strings.
*/
export function parseWeixinApiJson<T>(rawText: string): T {
let output = "";
let index = 0;
while (index < rawText.length) {
if (rawText[index] !== '"') {
output += rawText[index++];
continue;
}

const stringStart = index;
index++;
let escaped = false;
while (index < rawText.length) {
const char = rawText[index++];
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
break;
}
}
const stringToken = rawText.slice(stringStart, index);
output += stringToken;

let cursor = index;
while (/\s/.test(rawText[cursor] ?? "")) cursor++;
if (rawText[cursor] !== ":") continue;

let key: unknown;
try {
key = JSON.parse(stringToken);
} catch {
continue;
}
if (typeof key !== "string" || !LOSSLESS_ID_FIELDS.has(key)) continue;

output += rawText.slice(index, cursor + 1);
cursor++;
while (/\s/.test(rawText[cursor] ?? "")) {
output += rawText[cursor++];
}
const numberStart = cursor;
if (rawText[cursor] === "-") cursor++;
while (/\d/.test(rawText[cursor] ?? "")) cursor++;
if (cursor > numberStart && !(cursor === numberStart + 1 && rawText[numberStart] === "-")) {
output += `"${rawText.slice(numberStart, cursor)}"`;
index = cursor;
} else {
index = numberStart;
}
}
return JSON.parse(output) as T;
}

/** Send a single message downstream and return the server-assigned message ID. */
export async function sendMessage(
params: WeixinApiOptions & { body: SendMessageReq },
): Promise<void> {
): Promise<SendMessageResp> {
const rawText = await apiPostFetch({
baseUrl: params.baseUrl,
endpoint: "ilink/bot/sendmessage",
Expand All @@ -527,10 +588,11 @@ export async function sendMessage(
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
label: "sendMessage",
});
const resp: SendMessageResp = JSON.parse(rawText);
const resp = parseWeixinApiJson<SendMessageResp>(rawText);
if (resp.ret && resp.ret !== 0) {
throw new Error(`sendMessage ret=${resp.ret} errmsg=${resp.errmsg ?? "(none)"}`);
}
return resp;
}

/** Fetch bot config (includes typing_ticket) for a given user. */
Expand Down
17 changes: 16 additions & 1 deletion src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,18 @@ export interface VideoItem {
export interface RefMessage {
message_item?: MessageItem;
title?: string; // 摘要
/** Server message ID used when newer clients omit the quoted content. */
svr_id?: string;
/** Optional metadata describing a selected substring of the quoted message. */
partial_text?: PartialText;
}

export interface PartialText {
start: string;
end: string;
startindex: number;
endindex: number;
quotemd5: string;
}

export interface ToolCallStartItem {
Expand Down Expand Up @@ -179,7 +191,8 @@ export interface MessageItem {
/** Unified message (proto: WeixinMessage). Replaces the old split Message + MessageContent + FullMessage. */
export interface WeixinMessage {
seq?: number;
message_id?: number;
/** uint64 on the wire; parsed losslessly as a string. */
message_id?: string;
from_user_id?: string;
to_user_id?: string;
client_id?: string;
Expand Down Expand Up @@ -224,6 +237,8 @@ export interface SendMessageReq {
}

export interface SendMessageResp {
/** uint64 on the wire; parsed losslessly as a string. */
message_id?: string;
ret?: number;
errmsg?: string;
}
Expand Down
Loading