* fix(security): default standalone servers to loopback bind (#4) Change canvas host and telegram webhook default bind from 0.0.0.0 (all interfaces) to 127.0.0.1 (loopback only) to prevent unintended network exposure when no explicit host is configured. * fix: restore telegram webhook host override while keeping loopback defaults (openclaw#13184) thanks @davidrudduck * style: format telegram docs after rebase (openclaw#13184) thanks @davidrudduck --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
223 lines
5.9 KiB
TypeScript
223 lines
5.9 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { monitorTelegramProvider } from "./monitor.js";
|
|
|
|
type MockCtx = {
|
|
message: {
|
|
chat: { id: number; type: string; title?: string };
|
|
text?: string;
|
|
caption?: string;
|
|
};
|
|
me?: { username: string };
|
|
getFile: () => Promise<unknown>;
|
|
};
|
|
|
|
// Fake bot to capture handler and API calls
|
|
const handlers: Record<string, (ctx: MockCtx) => Promise<void> | void> = {};
|
|
const api = {
|
|
sendMessage: vi.fn(),
|
|
sendPhoto: vi.fn(),
|
|
sendVideo: vi.fn(),
|
|
sendAudio: vi.fn(),
|
|
sendDocument: vi.fn(),
|
|
setWebhook: vi.fn(),
|
|
deleteWebhook: vi.fn(),
|
|
};
|
|
const { initSpy, runSpy, loadConfig } = vi.hoisted(() => ({
|
|
initSpy: vi.fn(async () => undefined),
|
|
runSpy: vi.fn(() => ({
|
|
task: () => Promise.resolve(),
|
|
stop: vi.fn(),
|
|
})),
|
|
loadConfig: vi.fn(() => ({
|
|
agents: { defaults: { maxConcurrent: 2 } },
|
|
channels: { telegram: {} },
|
|
})),
|
|
}));
|
|
|
|
const { computeBackoff, sleepWithAbort } = vi.hoisted(() => ({
|
|
computeBackoff: vi.fn(() => 0),
|
|
sleepWithAbort: vi.fn(async () => undefined),
|
|
}));
|
|
const { startTelegramWebhookSpy } = vi.hoisted(() => ({
|
|
startTelegramWebhookSpy: vi.fn(async () => ({ server: { close: vi.fn() }, stop: vi.fn() })),
|
|
}));
|
|
|
|
vi.mock("../config/config.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../config/config.js")>();
|
|
return {
|
|
...actual,
|
|
loadConfig,
|
|
};
|
|
});
|
|
|
|
vi.mock("./bot.js", () => ({
|
|
createTelegramBot: () => {
|
|
handlers.message = async (ctx: MockCtx) => {
|
|
const chatId = ctx.message.chat.id;
|
|
const isGroup = ctx.message.chat.type !== "private";
|
|
const text = ctx.message.text ?? ctx.message.caption ?? "";
|
|
if (isGroup && !text.includes("@mybot")) {
|
|
return;
|
|
}
|
|
if (!text.trim()) {
|
|
return;
|
|
}
|
|
await api.sendMessage(chatId, `echo:${text}`, { parse_mode: "HTML" });
|
|
};
|
|
return {
|
|
on: vi.fn(),
|
|
api,
|
|
me: { username: "mybot" },
|
|
init: initSpy,
|
|
stop: vi.fn(),
|
|
start: vi.fn(),
|
|
};
|
|
},
|
|
createTelegramWebhookCallback: vi.fn(),
|
|
}));
|
|
|
|
// Mock the grammyjs/runner to resolve immediately
|
|
vi.mock("@grammyjs/runner", () => ({
|
|
run: runSpy,
|
|
}));
|
|
|
|
vi.mock("../infra/backoff.js", () => ({
|
|
computeBackoff,
|
|
sleepWithAbort,
|
|
}));
|
|
|
|
vi.mock("./webhook.js", () => ({
|
|
startTelegramWebhook: (...args: unknown[]) => startTelegramWebhookSpy(...args),
|
|
}));
|
|
|
|
vi.mock("../auto-reply/reply.js", () => ({
|
|
getReplyFromConfig: async (ctx: { Body?: string }) => ({
|
|
text: `echo:${ctx.Body}`,
|
|
}),
|
|
}));
|
|
|
|
describe("monitorTelegramProvider (grammY)", () => {
|
|
beforeEach(() => {
|
|
loadConfig.mockReturnValue({
|
|
agents: { defaults: { maxConcurrent: 2 } },
|
|
channels: { telegram: {} },
|
|
});
|
|
initSpy.mockClear();
|
|
runSpy.mockClear();
|
|
computeBackoff.mockClear();
|
|
sleepWithAbort.mockClear();
|
|
startTelegramWebhookSpy.mockClear();
|
|
});
|
|
|
|
it("processes a DM and sends reply", async () => {
|
|
Object.values(api).forEach((fn) => {
|
|
fn?.mockReset?.();
|
|
});
|
|
await monitorTelegramProvider({ token: "tok" });
|
|
expect(handlers.message).toBeDefined();
|
|
await handlers.message?.({
|
|
message: {
|
|
message_id: 1,
|
|
chat: { id: 123, type: "private" },
|
|
text: "hi",
|
|
},
|
|
me: { username: "mybot" },
|
|
getFile: vi.fn(async () => ({})),
|
|
});
|
|
expect(api.sendMessage).toHaveBeenCalledWith(123, "echo:hi", {
|
|
parse_mode: "HTML",
|
|
});
|
|
});
|
|
|
|
it("uses agent maxConcurrent for runner concurrency", async () => {
|
|
runSpy.mockClear();
|
|
loadConfig.mockReturnValue({
|
|
agents: { defaults: { maxConcurrent: 3 } },
|
|
channels: { telegram: {} },
|
|
});
|
|
|
|
await monitorTelegramProvider({ token: "tok" });
|
|
|
|
expect(runSpy).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
sink: { concurrency: 3 },
|
|
runner: expect.objectContaining({
|
|
silent: true,
|
|
maxRetryTime: 5 * 60 * 1000,
|
|
retryInterval: "exponential",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("requires mention in groups by default", async () => {
|
|
Object.values(api).forEach((fn) => {
|
|
fn?.mockReset?.();
|
|
});
|
|
await monitorTelegramProvider({ token: "tok" });
|
|
await handlers.message?.({
|
|
message: {
|
|
message_id: 2,
|
|
chat: { id: -99, type: "supergroup", title: "G" },
|
|
text: "hello all",
|
|
},
|
|
me: { username: "mybot" },
|
|
getFile: vi.fn(async () => ({})),
|
|
});
|
|
expect(api.sendMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("retries on recoverable network errors", async () => {
|
|
const networkError = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" });
|
|
runSpy
|
|
.mockImplementationOnce(() => ({
|
|
task: () => Promise.reject(networkError),
|
|
stop: vi.fn(),
|
|
}))
|
|
.mockImplementationOnce(() => ({
|
|
task: () => Promise.resolve(),
|
|
stop: vi.fn(),
|
|
}));
|
|
|
|
await monitorTelegramProvider({ token: "tok" });
|
|
|
|
expect(computeBackoff).toHaveBeenCalled();
|
|
expect(sleepWithAbort).toHaveBeenCalled();
|
|
expect(runSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("surfaces non-recoverable errors", async () => {
|
|
runSpy.mockImplementationOnce(() => ({
|
|
task: () => Promise.reject(new Error("bad token")),
|
|
stop: vi.fn(),
|
|
}));
|
|
|
|
await expect(monitorTelegramProvider({ token: "tok" })).rejects.toThrow("bad token");
|
|
});
|
|
|
|
it("passes configured webhookHost to webhook listener", async () => {
|
|
await monitorTelegramProvider({
|
|
token: "tok",
|
|
useWebhook: true,
|
|
webhookUrl: "https://example.test/telegram",
|
|
webhookSecret: "secret",
|
|
config: {
|
|
agents: { defaults: { maxConcurrent: 2 } },
|
|
channels: {
|
|
telegram: {
|
|
webhookHost: "0.0.0.0",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(startTelegramWebhookSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
host: "0.0.0.0",
|
|
}),
|
|
);
|
|
expect(runSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|