mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:52:25 +00:00
535 lines
16 KiB
TypeScript
535 lines
16 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../../../src/config/config.js";
|
|
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
|
|
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
|
|
import type { ResolvedTelegramAccount } from "./accounts.js";
|
|
import * as auditModule from "./audit.js";
|
|
import { telegramPlugin } from "./channel.js";
|
|
import * as monitorModule from "./monitor.js";
|
|
import * as probeModule from "./probe.js";
|
|
import { setTelegramRuntime } from "./runtime.js";
|
|
|
|
const probeTelegramMock = vi.hoisted(() => vi.fn());
|
|
const collectTelegramUnmentionedGroupIdsMock = vi.hoisted(() => vi.fn());
|
|
const auditTelegramGroupMembershipMock = vi.hoisted(() => vi.fn());
|
|
const monitorTelegramProviderMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("./probe.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./probe.js")>();
|
|
return {
|
|
...actual,
|
|
probeTelegram: probeTelegramMock,
|
|
};
|
|
});
|
|
|
|
vi.mock("./audit.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./audit.js")>();
|
|
return {
|
|
...actual,
|
|
collectTelegramUnmentionedGroupIds: collectTelegramUnmentionedGroupIdsMock,
|
|
auditTelegramGroupMembership: auditTelegramGroupMembershipMock,
|
|
};
|
|
});
|
|
|
|
vi.mock("./monitor.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./monitor.js")>();
|
|
return {
|
|
...actual,
|
|
monitorTelegramProvider: monitorTelegramProviderMock,
|
|
};
|
|
});
|
|
|
|
function createCfg(): OpenClawConfig {
|
|
return {
|
|
channels: {
|
|
telegram: {
|
|
enabled: true,
|
|
accounts: {
|
|
alerts: { botToken: "token-shared" },
|
|
work: { botToken: "token-shared" },
|
|
ops: { botToken: "token-ops" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
}
|
|
|
|
function resolveAccount(cfg: OpenClawConfig, accountId: string): ResolvedTelegramAccount {
|
|
return telegramPlugin.config.resolveAccount(cfg, accountId) as ResolvedTelegramAccount;
|
|
}
|
|
|
|
function createStartTelegramContext(cfg: OpenClawConfig, accountId: string) {
|
|
return createStartAccountContext({
|
|
account: resolveAccount(cfg, accountId),
|
|
cfg,
|
|
});
|
|
}
|
|
|
|
function startTelegramAccount(cfg: OpenClawConfig, accountId: string) {
|
|
return telegramPlugin.gateway!.startAccount!(createStartTelegramContext(cfg, accountId));
|
|
}
|
|
|
|
function installTelegramRuntime(telegram?: Record<string, unknown>) {
|
|
setTelegramRuntime({
|
|
channel: telegram ? { telegram } : undefined,
|
|
logging: {
|
|
shouldLogVerbose: () => false,
|
|
},
|
|
} as unknown as PluginRuntime);
|
|
}
|
|
|
|
function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: string }) {
|
|
const monitorTelegramProvider = vi
|
|
.spyOn(monitorModule, "monitorTelegramProvider")
|
|
.mockImplementation(async () => undefined);
|
|
const probeTelegram = vi
|
|
.spyOn(probeModule, "probeTelegram")
|
|
.mockImplementation(async () =>
|
|
params?.probeOk
|
|
? { ok: true, bot: { username: params.botUsername ?? "bot" }, elapsedMs: 0 }
|
|
: { ok: false, elapsedMs: 0 },
|
|
);
|
|
const collectUnmentionedGroupIds = vi
|
|
.spyOn(auditModule, "collectTelegramUnmentionedGroupIds")
|
|
.mockImplementation(() => ({
|
|
groupIds: [] as string[],
|
|
unresolvedGroups: 0,
|
|
hasWildcardUnmentionedGroups: false,
|
|
}));
|
|
const auditGroupMembership = vi
|
|
.spyOn(auditModule, "auditTelegramGroupMembership")
|
|
.mockImplementation(async () => ({
|
|
ok: true,
|
|
checkedGroups: 0,
|
|
unresolvedGroups: 0,
|
|
hasWildcardUnmentionedGroups: false,
|
|
groups: [],
|
|
elapsedMs: 0,
|
|
}));
|
|
installTelegramRuntime();
|
|
return {
|
|
monitorTelegramProvider,
|
|
probeTelegram,
|
|
collectUnmentionedGroupIds,
|
|
auditGroupMembership,
|
|
};
|
|
}
|
|
|
|
function configureOpsProxyNetwork(cfg: OpenClawConfig) {
|
|
cfg.channels!.telegram!.accounts!.ops = {
|
|
...cfg.channels!.telegram!.accounts!.ops,
|
|
proxy: "http://127.0.0.1:8888",
|
|
network: {
|
|
autoSelectFamily: false,
|
|
dnsResultOrder: "ipv4first",
|
|
},
|
|
};
|
|
}
|
|
|
|
function createOpsProxyAccount() {
|
|
const cfg = createCfg();
|
|
configureOpsProxyNetwork(cfg);
|
|
return {
|
|
cfg,
|
|
account: resolveAccount(cfg, "ops"),
|
|
};
|
|
}
|
|
|
|
function installSendMessageRuntime(
|
|
sendMessageTelegram: ReturnType<typeof vi.fn>,
|
|
): ReturnType<typeof vi.fn> {
|
|
installTelegramRuntime({
|
|
sendMessageTelegram,
|
|
});
|
|
return sendMessageTelegram;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("telegramPlugin groups", () => {
|
|
it("uses plugin-owned group policy resolvers", () => {
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
botToken: "telegram-test",
|
|
groups: {
|
|
"-1001": {
|
|
requireMention: true,
|
|
tools: { allow: ["message.send"] },
|
|
topics: {
|
|
"77": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(
|
|
telegramPlugin.groups?.resolveRequireMention?.({
|
|
cfg,
|
|
groupId: "-1001:topic:77",
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
telegramPlugin.groups?.resolveToolPolicy?.({
|
|
cfg,
|
|
groupId: "-1001:topic:77",
|
|
}),
|
|
).toEqual({ allow: ["message.send"] });
|
|
});
|
|
});
|
|
|
|
describe("telegramPlugin duplicate token guard", () => {
|
|
it("marks secondary account as not configured when token is shared", async () => {
|
|
const cfg = createCfg();
|
|
const alertsAccount = resolveAccount(cfg, "alerts");
|
|
const workAccount = resolveAccount(cfg, "work");
|
|
const opsAccount = resolveAccount(cfg, "ops");
|
|
|
|
expect(await telegramPlugin.config.isConfigured!(alertsAccount, cfg)).toBe(true);
|
|
expect(await telegramPlugin.config.isConfigured!(workAccount, cfg)).toBe(false);
|
|
expect(await telegramPlugin.config.isConfigured!(opsAccount, cfg)).toBe(true);
|
|
|
|
expect(telegramPlugin.config.unconfiguredReason?.(workAccount, cfg)).toContain(
|
|
'account "alerts"',
|
|
);
|
|
});
|
|
|
|
it("surfaces duplicate-token reason in status snapshot", async () => {
|
|
const cfg = createCfg();
|
|
const workAccount = resolveAccount(cfg, "work");
|
|
const snapshot = await telegramPlugin.status!.buildAccountSnapshot!({
|
|
account: workAccount,
|
|
cfg,
|
|
runtime: undefined,
|
|
probe: undefined,
|
|
audit: undefined,
|
|
});
|
|
|
|
expect(snapshot.configured).toBe(false);
|
|
expect(snapshot.lastError).toContain('account "alerts"');
|
|
});
|
|
|
|
it("blocks startup for duplicate token accounts before polling starts", async () => {
|
|
const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({
|
|
probeOk: true,
|
|
});
|
|
const cfg = createCfg();
|
|
|
|
await expect(startTelegramAccount(cfg, "work")).rejects.toThrow("Duplicate Telegram bot token");
|
|
|
|
expect(probeTelegramMock).not.toHaveBeenCalled();
|
|
expect(monitorTelegramProviderMock).not.toHaveBeenCalled();
|
|
expect(probeTelegram).not.toHaveBeenCalled();
|
|
expect(monitorTelegramProvider).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes webhookPort through to monitor startup options", async () => {
|
|
const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({
|
|
probeOk: true,
|
|
botUsername: "opsbot",
|
|
});
|
|
probeTelegramMock.mockResolvedValue({
|
|
ok: true,
|
|
bot: { username: "opsbot" },
|
|
elapsedMs: 1,
|
|
});
|
|
monitorTelegramProviderMock.mockResolvedValue(undefined);
|
|
|
|
const cfg = createCfg();
|
|
cfg.channels!.telegram!.accounts!.ops = {
|
|
...cfg.channels!.telegram!.accounts!.ops,
|
|
webhookUrl: "https://example.test/telegram-webhook",
|
|
webhookSecret: "secret", // pragma: allowlist secret
|
|
webhookPort: 9876,
|
|
};
|
|
|
|
await startTelegramAccount(cfg, "ops");
|
|
|
|
expect(probeTelegramMock).toHaveBeenCalledWith("token-ops", 2500, {
|
|
accountId: "ops",
|
|
proxyUrl: undefined,
|
|
network: undefined,
|
|
});
|
|
expect(monitorTelegramProviderMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
useWebhook: true,
|
|
webhookPort: 9876,
|
|
}),
|
|
);
|
|
expect(probeTelegram).toHaveBeenCalled();
|
|
expect(monitorTelegramProvider).toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes account proxy and network settings into Telegram probes", async () => {
|
|
const runtimeProbeTelegram = vi.fn(async () => {
|
|
throw new Error("runtime probe should not be used");
|
|
});
|
|
installTelegramRuntime({
|
|
probeTelegram: runtimeProbeTelegram,
|
|
});
|
|
probeTelegramMock.mockResolvedValue({
|
|
ok: true,
|
|
bot: { username: "opsbot" },
|
|
elapsedMs: 1,
|
|
});
|
|
|
|
const { cfg, account } = createOpsProxyAccount();
|
|
|
|
await telegramPlugin.status!.probeAccount!({
|
|
account,
|
|
timeoutMs: 5000,
|
|
cfg,
|
|
});
|
|
|
|
expect(probeTelegramMock).toHaveBeenCalledWith("token-ops", 5000, {
|
|
accountId: "ops",
|
|
proxyUrl: "http://127.0.0.1:8888",
|
|
network: {
|
|
autoSelectFamily: false,
|
|
dnsResultOrder: "ipv4first",
|
|
},
|
|
});
|
|
expect(runtimeProbeTelegram).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes account proxy and network settings into Telegram membership audits", async () => {
|
|
const runtimeCollectUnmentionedGroupIds = vi.fn(() => {
|
|
throw new Error("runtime audit helper should not be used");
|
|
});
|
|
const runtimeAuditGroupMembership = vi.fn(async () => {
|
|
throw new Error("runtime audit helper should not be used");
|
|
});
|
|
installTelegramRuntime({
|
|
collectUnmentionedGroupIds: runtimeCollectUnmentionedGroupIds,
|
|
auditGroupMembership: runtimeAuditGroupMembership,
|
|
});
|
|
collectTelegramUnmentionedGroupIdsMock.mockReturnValue({
|
|
groupIds: ["-100123"],
|
|
unresolvedGroups: 0,
|
|
hasWildcardUnmentionedGroups: false,
|
|
});
|
|
auditTelegramGroupMembershipMock.mockResolvedValue({
|
|
ok: true,
|
|
checkedGroups: 1,
|
|
unresolvedGroups: 0,
|
|
hasWildcardUnmentionedGroups: false,
|
|
groups: [],
|
|
elapsedMs: 1,
|
|
});
|
|
|
|
const { cfg, account } = createOpsProxyAccount();
|
|
cfg.channels!.telegram!.accounts!.ops = {
|
|
...cfg.channels!.telegram!.accounts!.ops,
|
|
groups: {
|
|
"-100123": { requireMention: false },
|
|
},
|
|
};
|
|
|
|
await telegramPlugin.status!.auditAccount!({
|
|
account,
|
|
timeoutMs: 5000,
|
|
probe: { ok: true, bot: { id: 123 }, elapsedMs: 1 },
|
|
cfg,
|
|
});
|
|
|
|
expect(collectTelegramUnmentionedGroupIdsMock).toHaveBeenCalledWith({
|
|
"-100123": { requireMention: false },
|
|
});
|
|
expect(auditTelegramGroupMembershipMock).toHaveBeenCalledWith({
|
|
token: "token-ops",
|
|
botId: 123,
|
|
groupIds: ["-100123"],
|
|
proxyUrl: "http://127.0.0.1:8888",
|
|
network: {
|
|
autoSelectFamily: false,
|
|
dnsResultOrder: "ipv4first",
|
|
},
|
|
timeoutMs: 5000,
|
|
});
|
|
expect(runtimeCollectUnmentionedGroupIds).not.toHaveBeenCalled();
|
|
expect(runtimeAuditGroupMembership).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("forwards mediaLocalRoots to sendMessageTelegram for outbound media sends", async () => {
|
|
const sendMessageTelegram = installSendMessageRuntime(
|
|
vi.fn(async () => ({ messageId: "tg-1" })),
|
|
);
|
|
|
|
const result = await telegramPlugin.outbound!.sendMedia!({
|
|
cfg: createCfg(),
|
|
to: "12345",
|
|
text: "hello",
|
|
mediaUrl: "/tmp/image.png",
|
|
mediaLocalRoots: ["/tmp/agent-root"],
|
|
accountId: "ops",
|
|
});
|
|
|
|
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
|
"12345",
|
|
"hello",
|
|
expect.objectContaining({
|
|
mediaUrl: "/tmp/image.png",
|
|
mediaLocalRoots: ["/tmp/agent-root"],
|
|
}),
|
|
);
|
|
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-1" });
|
|
});
|
|
|
|
it("preserves buttons for outbound text payload sends", async () => {
|
|
const sendMessageTelegram = installSendMessageRuntime(
|
|
vi.fn(async () => ({ messageId: "tg-2" })),
|
|
);
|
|
|
|
const result = await telegramPlugin.outbound!.sendPayload!({
|
|
cfg: createCfg(),
|
|
to: "12345",
|
|
text: "",
|
|
payload: {
|
|
text: "Approval required",
|
|
channelData: {
|
|
telegram: {
|
|
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
|
|
},
|
|
},
|
|
},
|
|
accountId: "ops",
|
|
});
|
|
|
|
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
|
"12345",
|
|
"Approval required",
|
|
expect.objectContaining({
|
|
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
|
|
}),
|
|
);
|
|
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-2" });
|
|
});
|
|
|
|
it("sends outbound payload media lists and keeps buttons on the first message only", async () => {
|
|
const sendMessageTelegram = installSendMessageRuntime(
|
|
vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ messageId: "tg-3", chatId: "12345" })
|
|
.mockResolvedValueOnce({ messageId: "tg-4", chatId: "12345" }),
|
|
);
|
|
|
|
const result = await telegramPlugin.outbound!.sendPayload!({
|
|
cfg: createCfg(),
|
|
to: "12345",
|
|
text: "",
|
|
payload: {
|
|
text: "Approval required",
|
|
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
|
channelData: {
|
|
telegram: {
|
|
quoteText: "quoted",
|
|
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
|
|
},
|
|
},
|
|
},
|
|
mediaLocalRoots: ["/tmp/media"],
|
|
accountId: "ops",
|
|
silent: true,
|
|
});
|
|
|
|
expect(sendMessageTelegram).toHaveBeenCalledTimes(2);
|
|
expect(sendMessageTelegram).toHaveBeenNthCalledWith(
|
|
1,
|
|
"12345",
|
|
"Approval required",
|
|
expect.objectContaining({
|
|
mediaUrl: "https://example.com/1.jpg",
|
|
mediaLocalRoots: ["/tmp/media"],
|
|
quoteText: "quoted",
|
|
silent: true,
|
|
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
|
|
}),
|
|
);
|
|
expect(sendMessageTelegram).toHaveBeenNthCalledWith(
|
|
2,
|
|
"12345",
|
|
"",
|
|
expect.objectContaining({
|
|
mediaUrl: "https://example.com/2.jpg",
|
|
mediaLocalRoots: ["/tmp/media"],
|
|
quoteText: "quoted",
|
|
silent: true,
|
|
}),
|
|
);
|
|
expect(
|
|
(sendMessageTelegram.mock.calls[1]?.[2] as Record<string, unknown>)?.buttons,
|
|
).toBeUndefined();
|
|
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-4" });
|
|
});
|
|
|
|
it("ignores accounts with missing tokens during duplicate-token checks", async () => {
|
|
const cfg = createCfg();
|
|
cfg.channels!.telegram!.accounts!.ops = {} as never;
|
|
|
|
const alertsAccount = resolveAccount(cfg, "alerts");
|
|
expect(await telegramPlugin.config.isConfigured!(alertsAccount, cfg)).toBe(true);
|
|
});
|
|
|
|
it("does not crash startup when a resolved account token is undefined", async () => {
|
|
const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({
|
|
probeOk: false,
|
|
});
|
|
probeTelegramMock.mockResolvedValue({ ok: false, elapsedMs: 1 });
|
|
monitorTelegramProviderMock.mockResolvedValue(undefined);
|
|
|
|
const cfg = createCfg();
|
|
const ctx = createStartTelegramContext(cfg, "ops");
|
|
ctx.account = {
|
|
...ctx.account,
|
|
token: undefined as unknown as string,
|
|
} as ResolvedTelegramAccount;
|
|
|
|
await expect(telegramPlugin.gateway!.startAccount!(ctx)).resolves.toBeUndefined();
|
|
expect(probeTelegramMock).toHaveBeenCalledWith("", 2500, {
|
|
accountId: "ops",
|
|
proxyUrl: undefined,
|
|
network: undefined,
|
|
});
|
|
expect(monitorTelegramProviderMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
token: "",
|
|
}),
|
|
);
|
|
expect(probeTelegram).toHaveBeenCalled();
|
|
expect(monitorTelegramProvider).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("telegramPlugin outbound sendPayload forceDocument", () => {
|
|
it("forwards forceDocument to the underlying send call when channelData is present", async () => {
|
|
const sendMessageTelegram = installSendMessageRuntime(
|
|
vi.fn(async () => ({ messageId: "tg-fd" })),
|
|
);
|
|
|
|
await telegramPlugin.outbound!.sendPayload!({
|
|
cfg: createCfg(),
|
|
to: "12345",
|
|
text: "",
|
|
payload: {
|
|
text: "here is an image",
|
|
mediaUrls: ["https://example.com/photo.png"],
|
|
channelData: { telegram: {} },
|
|
},
|
|
accountId: "ops",
|
|
forceDocument: true,
|
|
});
|
|
|
|
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
|
"12345",
|
|
expect.any(String),
|
|
expect.objectContaining({ forceDocument: true }),
|
|
);
|
|
});
|
|
});
|