feat: pending-finish proposals and QQ image input

Proposal semantics: worker results no longer distinguish success/fail;
only an explicit finish settles a pending proposal. Pending owner input
follows up by resuming the original worker session. Dirty pending blocks
start_next globally; clean pending only blocks its owner.

QQ adapter now downloads image attachments and passes them as ACP image
content blocks; video/file attachments degrade to link text.
This commit is contained in:
zenord
2026-08-19 13:36:23 +08:00
parent 33854721bf
commit 7aa610294c
24 changed files with 1285 additions and 391 deletions
+119
View File
@@ -31,6 +31,125 @@ test("group messages without member_openid fall back to user_openid", async () =
assert.equal(received[1].userId, "mem4");
});
test("image attachments are downloaded to base64 while video and file attachments degrade to text links", async () => {
const received: any[] = [];
const gateway = { receive: async (message: unknown) => { received.push(message); } };
const adapter = new QqAdapter(config, gateway as never);
const pngBytes = Buffer.from("fake-png-bytes");
const urls: string[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
urls.push(String(input));
return new Response(pngBytes, { status: 200 });
}) as typeof fetch;
try {
await adapter.handleWebhook({
body: {
op: 0, t: "C2C_MESSAGE_CREATE",
d: {
id: "m10", author: { user_openid: "u10" }, content: "看看这些",
attachments: [
{ content_type: "image/png", filename: "a.png", size: pngBytes.length, url: "//cdn.example.com/a.png" },
{ content_type: "video/mp4", filename: "v.mp4", size: 1024, url: "https://cdn.example.com/v.mp4" },
{ content_type: "application/pdf", filename: "f.pdf", size: 1024, url: "https://cdn.example.com/f.pdf" }
]
}
},
headers: {}, query: {}, req: {} as never
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received.length, 1);
const message = received[0];
assert.deepEqual(urls, ["https://cdn.example.com/a.png"]);
assert.equal(message.attachments.length, 1);
assert.equal(message.attachments[0].mimeType, "image/png");
assert.equal(message.attachments[0].filename, "a.png");
assert.equal(message.attachments[0].data, pngBytes.toString("base64"));
assert.match(message.text, /^看看这些/);
assert.match(message.text, /\[视频\] https:\/\/cdn\.example\.com\/v\.mp4/);
assert.match(message.text, /\[文件\] https:\/\/cdn\.example\.com\/f\.pdf/);
} finally { globalThis.fetch = original; }
});
test("oversized images are skipped and images beyond the per-message cap are not downloaded", async () => {
const received: any[] = [];
const gateway = { receive: async (message: unknown) => { received.push(message); } };
const adapter = new QqAdapter(config, gateway as never);
const urls: string[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
urls.push(String(input));
return new Response(Buffer.from("x"), { status: 200 });
}) as typeof fetch;
try {
await adapter.handleWebhook({
body: {
op: 0, t: "C2C_MESSAGE_CREATE",
d: {
id: "m11", author: { user_openid: "u11" }, content: "hi",
attachments: [
{ content_type: "image/png", size: 6 * 1024 * 1024, url: "https://cdn.example.com/big.png" },
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/1.png" },
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/2.png" },
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/3.png" },
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/4.png" }
]
}
},
headers: {}, query: {}, req: {} as never
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received.length, 1);
assert.deepEqual(urls, [
"https://cdn.example.com/1.png",
"https://cdn.example.com/2.png",
"https://cdn.example.com/3.png"
]);
assert.equal(received[0].attachments.length, 3);
} finally { globalThis.fetch = original; }
});
test("a pure image message uses placeholder text and failed downloads degrade", async () => {
const received: any[] = [];
const gateway = { receive: async (message: unknown) => { received.push(message); } };
const adapter = new QqAdapter(config, gateway as never);
const original = globalThis.fetch;
let fail = false;
globalThis.fetch = (async () => {
if (fail) throw new Error("network down");
return new Response(Buffer.from("img"), { status: 200 });
}) as typeof fetch;
try {
await adapter.handleWebhook({
body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m12", author: { user_openid: "u12" }, content: "", attachments: [{ content_type: "image/jpeg", url: "https://cdn.example.com/a.jpg" }] } },
headers: {}, query: {}, req: {} as never
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received.length, 1);
assert.equal(received[0].text, "(发来一张图片)");
assert.equal(received[0].attachments.length, 1);
// Every download failing with no text keeps the message ignored.
fail = true;
await adapter.handleWebhook({
body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m13", author: { user_openid: "u12" }, content: "", attachments: [{ content_type: "image/jpeg", url: "https://cdn.example.com/b.jpg" }] } },
headers: {}, query: {}, req: {} as never
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received.length, 1);
// Failed downloads with text still deliver the text without attachments.
await adapter.handleWebhook({
body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m14", author: { user_openid: "u12" }, content: "看图", attachments: [{ content_type: "image/jpeg", url: "https://cdn.example.com/c.jpg" }] } },
headers: {}, query: {}, req: {} as never
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received.length, 2);
assert.equal(received[1].text, "看图");
assert.equal(received[1].attachments, undefined);
} finally { globalThis.fetch = original; }
});
test("sendMessage uses C2C endpoint and forwards reply sequences as msg_seq", async () => {
const original = globalThis.fetch; const urls: string[] = []; const bodies: Array<Record<string, unknown>> = [];
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {