mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 12:18:37 +00:00
refactor: share googlechat api fetch handling
This commit is contained in:
@@ -13,6 +13,21 @@ const account = {
|
|||||||
config: {},
|
config: {},
|
||||||
} as ResolvedGoogleChatAccount;
|
} as ResolvedGoogleChatAccount;
|
||||||
|
|
||||||
|
function stubSuccessfulSend(name: string) {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(new Response(JSON.stringify({ name }), { status: 200 }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectDownloadToRejectForResponse(response: Response) {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
||||||
|
await expect(
|
||||||
|
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
|
||||||
|
).rejects.toThrow(/max bytes/i);
|
||||||
|
}
|
||||||
|
|
||||||
describe("downloadGoogleChatMedia", () => {
|
describe("downloadGoogleChatMedia", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
@@ -29,11 +44,7 @@ describe("downloadGoogleChatMedia", () => {
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-length": "50", "content-type": "application/octet-stream" },
|
headers: { "content-length": "50", "content-type": "application/octet-stream" },
|
||||||
});
|
});
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
await expectDownloadToRejectForResponse(response);
|
||||||
|
|
||||||
await expect(
|
|
||||||
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
|
|
||||||
).rejects.toThrow(/max bytes/i);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects when streamed payload exceeds max bytes", async () => {
|
it("rejects when streamed payload exceeds max bytes", async () => {
|
||||||
@@ -52,11 +63,7 @@ describe("downloadGoogleChatMedia", () => {
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-type": "application/octet-stream" },
|
headers: { "content-type": "application/octet-stream" },
|
||||||
});
|
});
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
await expectDownloadToRejectForResponse(response);
|
||||||
|
|
||||||
await expect(
|
|
||||||
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
|
|
||||||
).rejects.toThrow(/max bytes/i);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,12 +73,7 @@ describe("sendGoogleChatMessage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("adds messageReplyOption when sending to an existing thread", async () => {
|
it("adds messageReplyOption when sending to an existing thread", async () => {
|
||||||
const fetchMock = vi
|
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/123");
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ name: "spaces/AAA/messages/123" }), { status: 200 }),
|
|
||||||
);
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
|
|
||||||
await sendGoogleChatMessage({
|
await sendGoogleChatMessage({
|
||||||
account,
|
account,
|
||||||
@@ -89,12 +91,7 @@ describe("sendGoogleChatMessage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not set messageReplyOption for non-thread sends", async () => {
|
it("does not set messageReplyOption for non-thread sends", async () => {
|
||||||
const fetchMock = vi
|
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/124");
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ name: "spaces/AAA/messages/124" }), { status: 200 }),
|
|
||||||
);
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
|
|
||||||
await sendGoogleChatMessage({
|
await sendGoogleChatMessage({
|
||||||
account,
|
account,
|
||||||
|
|||||||
@@ -14,70 +14,24 @@ const headersToObject = (headers?: HeadersInit): Record<string, string> =>
|
|||||||
? Object.fromEntries(headers)
|
? Object.fromEntries(headers)
|
||||||
: headers || {};
|
: headers || {};
|
||||||
|
|
||||||
async function fetchJson<T>(
|
async function withGoogleChatResponse<T>(params: {
|
||||||
account: ResolvedGoogleChatAccount,
|
account: ResolvedGoogleChatAccount;
|
||||||
url: string,
|
url: string;
|
||||||
init: RequestInit,
|
init?: RequestInit;
|
||||||
): Promise<T> {
|
auditContext: string;
|
||||||
const token = await getGoogleChatAccessToken(account);
|
errorPrefix?: string;
|
||||||
const { response: res, release } = await fetchWithSsrFGuard({
|
handleResponse: (response: Response) => Promise<T>;
|
||||||
|
}): Promise<T> {
|
||||||
|
const {
|
||||||
|
account,
|
||||||
url,
|
url,
|
||||||
init: {
|
init,
|
||||||
...init,
|
auditContext,
|
||||||
headers: {
|
errorPrefix = "Google Chat API",
|
||||||
...headersToObject(init.headers),
|
handleResponse,
|
||||||
Authorization: `Bearer ${token}`,
|
} = params;
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
auditContext: "googlechat.api.json",
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as T;
|
|
||||||
} finally {
|
|
||||||
await release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchOk(
|
|
||||||
account: ResolvedGoogleChatAccount,
|
|
||||||
url: string,
|
|
||||||
init: RequestInit,
|
|
||||||
): Promise<void> {
|
|
||||||
const token = await getGoogleChatAccessToken(account);
|
const token = await getGoogleChatAccessToken(account);
|
||||||
const { response: res, release } = await fetchWithSsrFGuard({
|
const { response, release } = await fetchWithSsrFGuard({
|
||||||
url,
|
|
||||||
init: {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
...headersToObject(init.headers),
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
auditContext: "googlechat.api.ok",
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchBuffer(
|
|
||||||
account: ResolvedGoogleChatAccount,
|
|
||||||
url: string,
|
|
||||||
init?: RequestInit,
|
|
||||||
options?: { maxBytes?: number },
|
|
||||||
): Promise<{ buffer: Buffer; contentType?: string }> {
|
|
||||||
const token = await getGoogleChatAccessToken(account);
|
|
||||||
const { response: res, release } = await fetchWithSsrFGuard({
|
|
||||||
url,
|
url,
|
||||||
init: {
|
init: {
|
||||||
...init,
|
...init,
|
||||||
@@ -86,52 +40,103 @@ async function fetchBuffer(
|
|||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auditContext: "googlechat.api.buffer",
|
auditContext,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
if (!res.ok) {
|
if (!response.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await response.text().catch(() => "");
|
||||||
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
|
throw new Error(`${errorPrefix} ${response.status}: ${text || response.statusText}`);
|
||||||
}
|
}
|
||||||
const maxBytes = options?.maxBytes;
|
return await handleResponse(response);
|
||||||
const lengthHeader = res.headers.get("content-length");
|
|
||||||
if (maxBytes && lengthHeader) {
|
|
||||||
const length = Number(lengthHeader);
|
|
||||||
if (Number.isFinite(length) && length > maxBytes) {
|
|
||||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!maxBytes || !res.body) {
|
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
|
||||||
const contentType = res.headers.get("content-type") ?? undefined;
|
|
||||||
return { buffer, contentType };
|
|
||||||
}
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
let total = 0;
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!value) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
total += value.length;
|
|
||||||
if (total > maxBytes) {
|
|
||||||
await reader.cancel();
|
|
||||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
|
||||||
}
|
|
||||||
chunks.push(Buffer.from(value));
|
|
||||||
}
|
|
||||||
const buffer = Buffer.concat(chunks, total);
|
|
||||||
const contentType = res.headers.get("content-type") ?? undefined;
|
|
||||||
return { buffer, contentType };
|
|
||||||
} finally {
|
} finally {
|
||||||
await release();
|
await release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(
|
||||||
|
account: ResolvedGoogleChatAccount,
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
return await withGoogleChatResponse({
|
||||||
|
account,
|
||||||
|
url,
|
||||||
|
init: {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...headersToObject(init.headers),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auditContext: "googlechat.api.json",
|
||||||
|
handleResponse: async (response) => (await response.json()) as T,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOk(
|
||||||
|
account: ResolvedGoogleChatAccount,
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
): Promise<void> {
|
||||||
|
await withGoogleChatResponse({
|
||||||
|
account,
|
||||||
|
url,
|
||||||
|
init,
|
||||||
|
auditContext: "googlechat.api.ok",
|
||||||
|
handleResponse: async () => undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBuffer(
|
||||||
|
account: ResolvedGoogleChatAccount,
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
options?: { maxBytes?: number },
|
||||||
|
): Promise<{ buffer: Buffer; contentType?: string }> {
|
||||||
|
return await withGoogleChatResponse({
|
||||||
|
account,
|
||||||
|
url,
|
||||||
|
init,
|
||||||
|
auditContext: "googlechat.api.buffer",
|
||||||
|
handleResponse: async (res) => {
|
||||||
|
const maxBytes = options?.maxBytes;
|
||||||
|
const lengthHeader = res.headers.get("content-length");
|
||||||
|
if (maxBytes && lengthHeader) {
|
||||||
|
const length = Number(lengthHeader);
|
||||||
|
if (Number.isFinite(length) && length > maxBytes) {
|
||||||
|
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!maxBytes || !res.body) {
|
||||||
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
const contentType = res.headers.get("content-type") ?? undefined;
|
||||||
|
return { buffer, contentType };
|
||||||
|
}
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
total += value.length;
|
||||||
|
if (total > maxBytes) {
|
||||||
|
await reader.cancel();
|
||||||
|
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.from(value));
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks, total);
|
||||||
|
const contentType = res.headers.get("content-type") ?? undefined;
|
||||||
|
return { buffer, contentType };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendGoogleChatMessage(params: {
|
export async function sendGoogleChatMessage(params: {
|
||||||
account: ResolvedGoogleChatAccount;
|
account: ResolvedGoogleChatAccount;
|
||||||
space: string;
|
space: string;
|
||||||
@@ -208,34 +213,29 @@ export async function uploadGoogleChatAttachment(params: {
|
|||||||
Buffer.from(footer, "utf8"),
|
Buffer.from(footer, "utf8"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const token = await getGoogleChatAccessToken(account);
|
|
||||||
const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`;
|
const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`;
|
||||||
const { response: res, release } = await fetchWithSsrFGuard({
|
const payload = await withGoogleChatResponse<{
|
||||||
|
attachmentDataRef?: { attachmentUploadToken?: string };
|
||||||
|
}>({
|
||||||
|
account,
|
||||||
url,
|
url,
|
||||||
init: {
|
init: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"Content-Type": `multipart/related; boundary=${boundary}`,
|
"Content-Type": `multipart/related; boundary=${boundary}`,
|
||||||
},
|
},
|
||||||
body,
|
body,
|
||||||
},
|
},
|
||||||
auditContext: "googlechat.upload",
|
auditContext: "googlechat.upload",
|
||||||
|
errorPrefix: "Google Chat upload",
|
||||||
|
handleResponse: async (response) =>
|
||||||
|
(await response.json()) as {
|
||||||
|
attachmentDataRef?: { attachmentUploadToken?: string };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
try {
|
return {
|
||||||
if (!res.ok) {
|
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
|
||||||
const text = await res.text().catch(() => "");
|
};
|
||||||
throw new Error(`Google Chat upload ${res.status}: ${text || res.statusText}`);
|
|
||||||
}
|
|
||||||
const payload = (await res.json()) as {
|
|
||||||
attachmentDataRef?: { attachmentUploadToken?: string };
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
await release();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadGoogleChatMedia(params: {
|
export async function downloadGoogleChatMedia(params: {
|
||||||
|
|||||||
Reference in New Issue
Block a user