Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
// Telegram tests cover bot.fetch abort plugin behavior.
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { getTelegramNetworkErrorOrigin } from "./network-errors.js";
|
|
|
|
const { botCtorSpy, telegramBotDepsForTest, telegramBotRuntimeForTest } =
|
|
await import("./bot.create-telegram-bot.test-harness.js");
|
|
const { createTelegramBotCore: createTelegramBotBase, setTelegramBotRuntimeForTest } =
|
|
await import("./bot-core.js");
|
|
setTelegramBotRuntimeForTest(
|
|
telegramBotRuntimeForTest as unknown as Parameters<typeof setTelegramBotRuntimeForTest>[0],
|
|
);
|
|
const createTelegramBot = (opts: import("./bot.types.js").TelegramBotOptions) =>
|
|
createTelegramBotBase({
|
|
...opts,
|
|
telegramDeps: telegramBotDepsForTest,
|
|
});
|
|
|
|
function createWrappedTelegramClientFetch(
|
|
proxyFetch: typeof fetch,
|
|
config?: import("openclaw/plugin-sdk/config-contracts").OpenClawConfig,
|
|
) {
|
|
const shutdown = new AbortController();
|
|
botCtorSpy.mockClear();
|
|
createTelegramBot({
|
|
token: "tok",
|
|
...(config ? { config } : {}),
|
|
fetchAbortSignal: shutdown.signal,
|
|
proxyFetch,
|
|
});
|
|
const clientFetch = (botCtorSpy.mock.calls.at(-1)?.[1] as { client?: { fetch?: unknown } })
|
|
?.client?.fetch as (input: RequestInfo | URL, init?: RequestInit) => Promise<unknown>;
|
|
expect(clientFetch).toBeTypeOf("function");
|
|
return { clientFetch, shutdown };
|
|
}
|
|
|
|
function createWrappedTelegramClientFetchWithTransport(params: {
|
|
fetch: typeof fetch;
|
|
forceFallback?: (reason: string) => boolean;
|
|
}) {
|
|
const shutdown = new AbortController();
|
|
botCtorSpy.mockClear();
|
|
createTelegramBot({
|
|
token: "tok",
|
|
fetchAbortSignal: shutdown.signal,
|
|
telegramTransport: {
|
|
fetch: params.fetch,
|
|
sourceFetch: params.fetch,
|
|
close: async () => undefined,
|
|
...(params.forceFallback ? { forceFallback: params.forceFallback } : {}),
|
|
},
|
|
});
|
|
const clientFetch = (botCtorSpy.mock.calls.at(-1)?.[1] as { client?: { fetch?: unknown } })
|
|
?.client?.fetch as (input: RequestInfo | URL, init?: RequestInit) => Promise<unknown>;
|
|
expect(clientFetch).toBeTypeOf("function");
|
|
return { clientFetch, shutdown };
|
|
}
|
|
|
|
describe("createTelegramBot fetch abort", () => {
|
|
it("aborts wrapped client fetch when fetchAbortSignal aborts", async () => {
|
|
const fetchSpy = vi.fn(
|
|
(_input: RequestInfo | URL, init?: RequestInit) =>
|
|
new Promise<AbortSignal>((resolve) => {
|
|
const signal = init?.signal as AbortSignal;
|
|
signal.addEventListener("abort", () => resolve(signal), { once: true });
|
|
}),
|
|
);
|
|
const { clientFetch, shutdown } = createWrappedTelegramClientFetch(
|
|
fetchSpy as unknown as typeof fetch,
|
|
);
|
|
|
|
const observedSignalPromise = clientFetch("https://example.test");
|
|
shutdown.abort(new Error("shutdown"));
|
|
const observedSignal = (await observedSignalPromise) as AbortSignal;
|
|
|
|
expect(observedSignal).toBeInstanceOf(AbortSignal);
|
|
expect(observedSignal.aborted).toBe(true);
|
|
});
|
|
|
|
it("tags wrapped Telegram fetch failures with the Bot API method", async () => {
|
|
const fetchError = Object.assign(new TypeError("fetch failed"), {
|
|
cause: Object.assign(new Error("connect timeout"), {
|
|
code: "UND_ERR_CONNECT_TIMEOUT",
|
|
}),
|
|
});
|
|
const fetchSpy = vi.fn(async () => {
|
|
throw fetchError;
|
|
});
|
|
const { clientFetch } = createWrappedTelegramClientFetch(fetchSpy as unknown as typeof fetch);
|
|
|
|
await expect(clientFetch("https://api.telegram.org/bot123456:ABC/getUpdates")).rejects.toBe(
|
|
fetchError,
|
|
);
|
|
expect(getTelegramNetworkErrorOrigin(fetchError)).toEqual({
|
|
method: "getupdates",
|
|
url: "https://api.telegram.org/bot123456:ABC/getUpdates",
|
|
});
|
|
});
|
|
|
|
it("aborts wrapped getUpdates fetch after the hard polling timeout", async () => {
|
|
vi.useFakeTimers();
|
|
const fetchSpy = vi.fn(
|
|
(_input: RequestInfo | URL, init?: RequestInit) =>
|
|
new Promise<AbortSignal>((resolve) => {
|
|
const signal = init?.signal as AbortSignal;
|
|
signal.addEventListener("abort", () => resolve(signal), { once: true });
|
|
}),
|
|
);
|
|
const { clientFetch } = createWrappedTelegramClientFetch(fetchSpy as unknown as typeof fetch);
|
|
|
|
const observedSignalPromise = clientFetch("https://api.telegram.org/bot123456:ABC/getUpdates");
|
|
await vi.advanceTimersByTimeAsync(45_000);
|
|
const observedSignal = (await observedSignalPromise) as AbortSignal;
|
|
|
|
expect(observedSignal).toBeInstanceOf(AbortSignal);
|
|
expect(observedSignal.aborted).toBe(true);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("uses the longer outbound text timeout for sendMessage", async () => {
|
|
vi.useFakeTimers();
|
|
const fetchSpy = vi.fn(
|
|
(_input: RequestInfo | URL, init?: RequestInit) =>
|
|
new Promise<AbortSignal>((resolve) => {
|
|
const signal = init?.signal as AbortSignal;
|
|
signal.addEventListener("abort", () => resolve(signal), { once: true });
|
|
}),
|
|
);
|
|
const { clientFetch } = createWrappedTelegramClientFetch(fetchSpy as unknown as typeof fetch);
|
|
|
|
const observedSignalPromise = clientFetch("https://api.telegram.org/bot123456:ABC/sendMessage");
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
const observedSignal = (await observedSignalPromise) as AbortSignal;
|
|
|
|
expect(observedSignal).toBeInstanceOf(AbortSignal);
|
|
expect(observedSignal.aborted).toBe(true);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("lets configured timeoutSeconds extend outbound method guards", async () => {
|
|
vi.useFakeTimers();
|
|
const fetchSpy = vi.fn(
|
|
(_input: RequestInfo | URL, init?: RequestInit) =>
|
|
new Promise<AbortSignal>((resolve) => {
|
|
const signal = init?.signal as AbortSignal;
|
|
signal.addEventListener("abort", () => resolve(signal), { once: true });
|
|
}),
|
|
);
|
|
const { clientFetch } = createWrappedTelegramClientFetch(
|
|
fetchSpy as unknown as typeof fetch,
|
|
{
|
|
channels: { telegram: { timeoutSeconds: 90 } },
|
|
} as never,
|
|
);
|
|
|
|
const observedSignalPromise = clientFetch(
|
|
"https://api.telegram.org/bot123456:ABC/editMessageText",
|
|
);
|
|
await vi.advanceTimersByTimeAsync(90_000);
|
|
const observedSignal = (await observedSignalPromise) as AbortSignal;
|
|
|
|
expect(observedSignal).toBeInstanceOf(AbortSignal);
|
|
expect(observedSignal.aborted).toBe(true);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("retries timed-out control calls once after forcing transport fallback", async () => {
|
|
vi.useFakeTimers();
|
|
const forceFallback = vi.fn(() => true);
|
|
const fetchSpy = vi
|
|
.fn()
|
|
.mockImplementationOnce(
|
|
(_input: RequestInfo | URL, init?: RequestInit) =>
|
|
new Promise((_resolve, reject) => {
|
|
const signal = init?.signal as AbortSignal;
|
|
signal.addEventListener(
|
|
"abort",
|
|
() => reject(toLintErrorObject(signal.reason, "Non-Error rejection")),
|
|
{ once: true },
|
|
);
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce({ ok: true } as Response);
|
|
const { clientFetch } = createWrappedTelegramClientFetchWithTransport({
|
|
fetch: fetchSpy as unknown as typeof fetch,
|
|
forceFallback,
|
|
});
|
|
|
|
const resultPromise = clientFetch("https://api.telegram.org/bot123456:ABC/deleteWebhook");
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
|
|
await expect(resultPromise).resolves.toEqual({ ok: true });
|
|
expect(forceFallback).toHaveBeenCalledWith("request-timeout");
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it.each(["deleteMyCommands", "setMyCommands"])(
|
|
"retries timed-out command sync call %s once after forcing transport fallback",
|
|
async (method) => {
|
|
vi.useFakeTimers();
|
|
const forceFallback = vi.fn(() => true);
|
|
const fetchSpy = vi
|
|
.fn()
|
|
.mockImplementationOnce(
|
|
(_input: RequestInfo | URL, init?: RequestInit) =>
|
|
new Promise((_resolve, reject) => {
|
|
const signal = init?.signal as AbortSignal;
|
|
signal.addEventListener(
|
|
"abort",
|
|
() => reject(toLintErrorObject(signal.reason, "Non-Error rejection")),
|
|
{ once: true },
|
|
);
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce({ ok: true } as Response);
|
|
const { clientFetch } = createWrappedTelegramClientFetchWithTransport({
|
|
fetch: fetchSpy as unknown as typeof fetch,
|
|
forceFallback,
|
|
});
|
|
|
|
const resultPromise = clientFetch(`https://api.telegram.org/bot123456:ABC/${method}`);
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
|
|
await expect(resultPromise).resolves.toEqual({ ok: true });
|
|
expect(forceFallback).toHaveBeenCalledWith("request-timeout");
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
vi.useRealTimers();
|
|
},
|
|
);
|
|
|
|
it("retries timed-out sendChatAction once after forcing transport fallback", async () => {
|
|
vi.useFakeTimers();
|
|
const forceFallback = vi.fn(() => true);
|
|
const fetchSpy = vi
|
|
.fn()
|
|
.mockImplementationOnce(
|
|
(_input: RequestInfo | URL, init?: RequestInit) =>
|
|
new Promise((_resolve, reject) => {
|
|
const signal = init?.signal as AbortSignal;
|
|
signal.addEventListener(
|
|
"abort",
|
|
() => reject(toLintErrorObject(signal.reason, "Non-Error rejection")),
|
|
{ once: true },
|
|
);
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce({ ok: true } as Response);
|
|
const { clientFetch } = createWrappedTelegramClientFetchWithTransport({
|
|
fetch: fetchSpy as unknown as typeof fetch,
|
|
forceFallback,
|
|
});
|
|
|
|
const resultPromise = clientFetch("https://api.telegram.org/bot123456:ABC/sendChatAction");
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
|
|
await expect(resultPromise).resolves.toEqual({ ok: true });
|
|
expect(forceFallback).toHaveBeenCalledWith("request-timeout");
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("retries Telegram 421 responses after forcing transport fallback", async () => {
|
|
const forceFallback = vi.fn(() => true);
|
|
const fetchSpy = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(new Response("Misdirected Request", { status: 421 }))
|
|
.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
|
const { clientFetch } = createWrappedTelegramClientFetchWithTransport({
|
|
fetch: fetchSpy as typeof fetch,
|
|
forceFallback,
|
|
});
|
|
|
|
const result = await clientFetch("https://api.telegram.org/bot123456:ABC/sendMessage");
|
|
|
|
expect(result).toBeInstanceOf(Response);
|
|
expect((result as Response).status).toBe(200);
|
|
expect(forceFallback).toHaveBeenCalledWith("misdirected-request");
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("retries Telegram 421 fetch errors after forcing transport fallback", async () => {
|
|
const forceFallback = vi.fn(() => true);
|
|
const fetchSpy = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(Object.assign(new Error("421 Misdirected Request"), { status: 421 }))
|
|
.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
|
const { clientFetch } = createWrappedTelegramClientFetchWithTransport({
|
|
fetch: fetchSpy as typeof fetch,
|
|
forceFallback,
|
|
});
|
|
|
|
const result = await clientFetch("https://api.telegram.org/bot123456:ABC/sendMessage");
|
|
|
|
expect(result).toBeInstanceOf(Response);
|
|
expect((result as Response).status).toBe(200);
|
|
expect(forceFallback).toHaveBeenCalledWith("misdirected-request");
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("preserves the original fetch error when tagging cannot attach metadata", async () => {
|
|
const frozenError = Object.freeze(
|
|
Object.assign(new TypeError("fetch failed"), {
|
|
cause: Object.assign(new Error("connect timeout"), {
|
|
code: "UND_ERR_CONNECT_TIMEOUT",
|
|
}),
|
|
}),
|
|
);
|
|
const fetchSpy = vi.fn(async () => {
|
|
throw toLintErrorObject(frozenError, "Non-Error thrown");
|
|
});
|
|
const { clientFetch } = createWrappedTelegramClientFetch(fetchSpy as unknown as typeof fetch);
|
|
|
|
await expect(clientFetch("https://api.telegram.org/bot123456:ABC/getUpdates")).rejects.toBe(
|
|
frozenError,
|
|
);
|
|
expect(getTelegramNetworkErrorOrigin(frozenError)).toBeNull();
|
|
});
|
|
});
|
|
|
|
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
|
if (value instanceof Error) {
|
|
return value;
|
|
}
|
|
if (typeof value === "string") {
|
|
return new Error(value);
|
|
}
|
|
const error = new Error(fallbackMessage, { cause: value });
|
|
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
|
Object.assign(error, value);
|
|
}
|
|
return error;
|
|
}
|