diff --git a/extensions/googlechat/src/api.ts b/extensions/googlechat/src/api.ts index c71c4491650..de611f66af5 100644 --- a/extensions/googlechat/src/api.ts +++ b/extensions/googlechat/src/api.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { getGoogleChatAccessToken } from "./auth.js"; import type { GoogleChatReaction } from "./types.js"; @@ -19,19 +20,27 @@ async function fetchJson( init: RequestInit, ): Promise { const token = await getGoogleChatAccessToken(account); - const res = await fetch(url, { - ...init, - headers: { - ...headersToObject(init.headers), - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", + const { response: res, release } = await fetchWithSsrFGuard({ + url, + init: { + ...init, + headers: { + ...headersToObject(init.headers), + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, }, + auditContext: "googlechat.api.json", }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); + 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(); } - return (await res.json()) as T; } async function fetchOk( @@ -40,16 +49,24 @@ async function fetchOk( init: RequestInit, ): Promise { const token = await getGoogleChatAccessToken(account); - const res = await fetch(url, { - ...init, - headers: { - ...headersToObject(init.headers), - Authorization: `Bearer ${token}`, + const { response: res, release } = await fetchWithSsrFGuard({ + url, + init: { + ...init, + headers: { + ...headersToObject(init.headers), + Authorization: `Bearer ${token}`, + }, }, + auditContext: "googlechat.api.ok", }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); + try { + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); + } + } finally { + await release(); } } @@ -60,51 +77,59 @@ async function fetchBuffer( options?: { maxBytes?: number }, ): Promise<{ buffer: Buffer; contentType?: string }> { const token = await getGoogleChatAccessToken(account); - const res = await fetch(url, { - ...init, - headers: { - ...headersToObject(init?.headers), - Authorization: `Bearer ${token}`, + const { response: res, release } = await fetchWithSsrFGuard({ + url, + init: { + ...init, + headers: { + ...headersToObject(init?.headers), + Authorization: `Bearer ${token}`, + }, }, + auditContext: "googlechat.api.buffer", }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); - } - 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})`); + try { + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); } - } - if (!maxBytes || !res.body) { - const buffer = Buffer.from(await res.arrayBuffer()); + 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 }; + } finally { + await release(); } - 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: { @@ -185,24 +210,32 @@ export async function uploadGoogleChatAttachment(params: { const token = await getGoogleChatAccessToken(account); const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`; - const res = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": `multipart/related; boundary=${boundary}`, + const { response: res, release } = await fetchWithSsrFGuard({ + url, + init: { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": `multipart/related; boundary=${boundary}`, + }, + body, }, - body, + auditContext: "googlechat.upload", }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Google Chat upload ${res.status}: ${text || res.statusText}`); + try { + if (!res.ok) { + 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(); } - const payload = (await res.json()) as { - attachmentDataRef?: { attachmentUploadToken?: string }; - }; - return { - attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken, - }; } export async function downloadGoogleChatMedia(params: { diff --git a/src/agents/openclaw-tools.plugin-context.test.ts b/src/agents/openclaw-tools.plugin-context.test.ts index 3609c0a7940..990b350752c 100644 --- a/src/agents/openclaw-tools.plugin-context.test.ts +++ b/src/agents/openclaw-tools.plugin-context.test.ts @@ -6,7 +6,7 @@ const resolvePluginToolsMock = vi.fn((params?: unknown) => { }); vi.mock("../plugins/tools.js", () => ({ - resolvePluginTools: (params: unknown) => resolvePluginToolsMock(params), + resolvePluginTools: resolvePluginToolsMock, })); import { createOpenClawTools } from "./openclaw-tools.js"; diff --git a/src/cron/schedule.test.ts b/src/cron/schedule.test.ts index fd80fef42eb..493897f2ef0 100644 --- a/src/cron/schedule.test.ts +++ b/src/cron/schedule.test.ts @@ -73,6 +73,16 @@ describe("cron schedule", () => { expect(next).toBe(anchor + 30_000); }); + it("never returns a past timestamp for Asia/Shanghai daily schedule (#30351)", () => { + const nowMs = Date.parse("2026-03-01T00:00:00.000Z"); + const next = computeNextRunAtMs( + { kind: "cron", expr: "0 8 * * *", tz: "Asia/Shanghai" }, + nowMs, + ); + expect(next).toBeDefined(); + expect(next!).toBeGreaterThan(nowMs); + }); + describe("cron with specific seconds (6-field pattern)", () => { // Pattern: fire at exactly second 0 of minute 0 of hour 12 every day const dailyNoon = { kind: "cron" as const, expr: "0 0 12 * * *", tz: "UTC" }; diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index bb7bdfc0ea6..a3acd344e62 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -54,25 +54,39 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe timezone: resolveCronTimezone(schedule.tz), catch: false, }); - const next = cron.nextRun(new Date(nowMs)); + let next = cron.nextRun(new Date(nowMs)); if (!next) { return undefined; } - const nextMs = next.getTime(); + let nextMs = next.getTime(); if (!Number.isFinite(nextMs)) { return undefined; } - if (nextMs > nowMs) { - return nextMs; - } - // Guard against same-second rescheduling loops: if croner returns - // "now" (or an earlier instant), retry from the next whole second. - const nextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000; - const retry = cron.nextRun(new Date(nextSecondMs)); - if (!retry) { + // Workaround for croner year-rollback bug: some timezone/date combinations + // (e.g. Asia/Shanghai) cause nextRun to return a timestamp in a past year. + // Retry from a later reference point when the returned time is not in the + // future. + if (nextMs <= nowMs) { + const nextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000; + const retry = cron.nextRun(new Date(nextSecondMs)); + if (retry) { + const retryMs = retry.getTime(); + if (Number.isFinite(retryMs) && retryMs > nowMs) { + return retryMs; + } + } + // Still in the past — try from start of tomorrow (UTC) as a broader reset. + const tomorrowMs = new Date(nowMs).setUTCHours(24, 0, 0, 0); + const retry2 = cron.nextRun(new Date(tomorrowMs)); + if (retry2) { + const retry2Ms = retry2.getTime(); + if (Number.isFinite(retry2Ms) && retry2Ms > nowMs) { + return retry2Ms; + } + } return undefined; } - const retryMs = retry.getTime(); - return Number.isFinite(retryMs) && retryMs > nowMs ? retryMs : undefined; + + return nextMs; }