Skip to content

Commit bb99be4

Browse files
fix(qqbot): chunk c2c byte-source media uploads
1 parent deb987d commit bb99be4

2 files changed

Lines changed: 117 additions & 7 deletions

File tree

extensions/qqbot/src/engine/messaging/sender.test.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
// Qqbot tests cover unified sender behavior.
2-
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
36
import { ChunkedMediaApi } from "../api/media-chunked.js";
47
import { MediaApi } from "../api/media.js";
58
import { MediaFileType, type MessageResponse, type UploadMediaResponse } from "../types.js";
9+
import type { RawMediaSource } from "./media-source.js";
610
import { registerAccount, sendMedia } from "./sender.js";
711

812
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
@@ -43,6 +47,16 @@ const logger = {
4347
debug: vi.fn(),
4448
};
4549

50+
const tempDirs: string[] = [];
51+
52+
async function createLocalMediaFile(name: string, bytes: Buffer): Promise<string> {
53+
const dir = await mkdtemp(join(tmpdir(), "qqbot-sender-test-"));
54+
tempDirs.push(dir);
55+
const filePath = join(dir, name);
56+
await writeFile(filePath, bytes);
57+
return filePath;
58+
}
59+
4660
function mockGuardedDownload(): void {
4761
fetchWithSsrFGuardMock.mockResolvedValueOnce({
4862
response: new Response(MEDIA_BYTES),
@@ -62,6 +76,10 @@ describe("qqbot unified sender media upload dispatch", () => {
6276
logger.debug.mockReset();
6377
});
6478

79+
afterEach(async () => {
80+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
81+
});
82+
6583
it.each([
6684
{
6785
label: "image",
@@ -134,4 +152,82 @@ describe("qqbot unified sender media upload dispatch", () => {
134152
});
135153
},
136154
);
155+
156+
it.each([
157+
{
158+
label: "base64",
159+
source: async (): Promise<RawMediaSource> => ({ base64: MEDIA_BYTES.toString("base64") }),
160+
expectedSource: { kind: "buffer" as const, buffer: Buffer.from(MEDIA_BYTES) },
161+
},
162+
{
163+
label: "buffer",
164+
source: async (): Promise<RawMediaSource> => ({
165+
buffer: Buffer.from(MEDIA_BYTES),
166+
fileName: "buffer-proof.png",
167+
}),
168+
expectedSource: {
169+
kind: "buffer" as const,
170+
buffer: Buffer.from(MEDIA_BYTES),
171+
fileName: "buffer-proof.png",
172+
},
173+
},
174+
{
175+
label: "localPath",
176+
source: async (): Promise<RawMediaSource> => ({
177+
localPath: await createLocalMediaFile("local-proof.png", MEDIA_BYTES),
178+
}),
179+
expectedSource: { kind: "localPath" as const },
180+
},
181+
])(
182+
"uploads C2C image $label byte sources through chunked upload instead of one-shot file_data",
183+
async ({ source, expectedSource }) => {
184+
const uploadMediaSpy = vi
185+
.spyOn(MediaApi.prototype, "uploadMedia")
186+
.mockResolvedValue(UPLOAD_RESPONSE);
187+
const uploadChunkedSpy = vi
188+
.spyOn(ChunkedMediaApi.prototype, "uploadChunked")
189+
.mockResolvedValue(UPLOAD_RESPONSE);
190+
vi.spyOn(MediaApi.prototype, "sendMediaMessage").mockResolvedValue(MESSAGE_RESPONSE);
191+
const appId = `sender-test-${expectedSource.kind}`;
192+
const creds = { appId, clientSecret: "client-secret" };
193+
registerAccount(appId, { logger });
194+
195+
const result = await sendMedia({
196+
target: { type: "c2c", id: "user-openid" },
197+
creds,
198+
kind: "image",
199+
source: await source(),
200+
content: "caption",
201+
});
202+
203+
expect(result).toBe(MESSAGE_RESPONSE);
204+
expect(uploadMediaSpy).not.toHaveBeenCalled();
205+
expect(uploadChunkedSpy).toHaveBeenCalledOnce();
206+
expect(uploadChunkedSpy).toHaveBeenCalledWith(
207+
expect.objectContaining({
208+
scope: "c2c",
209+
targetId: "user-openid",
210+
fileType: MediaFileType.IMAGE,
211+
creds,
212+
}),
213+
);
214+
const chunkedSource = uploadChunkedSpy.mock.calls[0]?.[0]?.source;
215+
expect(chunkedSource?.kind).toBe(expectedSource.kind);
216+
if (expectedSource.kind === "buffer") {
217+
expect(chunkedSource).toMatchObject({
218+
kind: "buffer",
219+
fileName: expectedSource.fileName,
220+
});
221+
expect(chunkedSource?.kind === "buffer" ? chunkedSource.buffer : undefined).toEqual(
222+
expectedSource.buffer,
223+
);
224+
}
225+
if (expectedSource.kind === "localPath") {
226+
expect(chunkedSource).toMatchObject({
227+
kind: "localPath",
228+
size: MEDIA_BYTES.length,
229+
});
230+
}
231+
},
232+
);
137233
});

extensions/qqbot/src/engine/messaging/sender.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,10 @@ async function sendMediaInternal(
634634
}
635635
}
636636

637+
function requiresC2CChunkedUpload(scope: ChatScope, fileType: MediaFileType): boolean {
638+
return scope === "c2c" && (fileType === MediaFileType.IMAGE || fileType === MediaFileType.FILE);
639+
}
640+
637641
async function dispatchUpload(
638642
ctx: AccountContext,
639643
scope: ChatScope,
@@ -648,10 +652,7 @@ async function dispatchUpload(
648652
const buffer = await downloadDirectUploadUrl(source.url, {
649653
maxBytes: getMaxUploadSize(fileType),
650654
});
651-
if (
652-
scope === "c2c" &&
653-
(fileType === MediaFileType.IMAGE || fileType === MediaFileType.FILE)
654-
) {
655+
if (requiresC2CChunkedUpload(scope, fileType)) {
655656
return ctx.chunkedMediaApi.uploadChunked({
656657
scope,
657658
targetId,
@@ -677,12 +678,22 @@ async function dispatchUpload(
677678
});
678679
}
679680
case "base64":
681+
if (requiresC2CChunkedUpload(scope, fileType)) {
682+
return ctx.chunkedMediaApi.uploadChunked({
683+
scope,
684+
targetId,
685+
fileType,
686+
source: { kind: "buffer", buffer: Buffer.from(source.data, "base64"), fileName },
687+
creds,
688+
fileName,
689+
});
690+
}
680691
return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
681692
fileData: source.data,
682693
fileName,
683694
});
684695
case "localPath":
685-
if (source.size >= LARGE_FILE_THRESHOLD) {
696+
if (requiresC2CChunkedUpload(scope, fileType) || source.size >= LARGE_FILE_THRESHOLD) {
686697
return ctx.chunkedMediaApi.uploadChunked({
687698
scope,
688699
targetId,
@@ -703,7 +714,10 @@ async function dispatchUpload(
703714
fileName,
704715
});
705716
case "buffer":
706-
if (source.buffer.length >= LARGE_FILE_THRESHOLD) {
717+
if (
718+
requiresC2CChunkedUpload(scope, fileType) ||
719+
source.buffer.length >= LARGE_FILE_THRESHOLD
720+
) {
707721
return ctx.chunkedMediaApi.uploadChunked({
708722
scope,
709723
targetId,

0 commit comments

Comments
 (0)