mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(telegram): guard duplicate bot token accounts
This commit is contained in:
125
extensions/telegram/src/channel.test.ts
Normal file
125
extensions/telegram/src/channel.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelGatewayContext,
|
||||
OpenClawConfig,
|
||||
PluginRuntime,
|
||||
ResolvedTelegramAccount,
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { telegramPlugin } from "./channel.js";
|
||||
import { setTelegramRuntime } from "./runtime.js";
|
||||
|
||||
function createCfg(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
telegram: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
alerts: { botToken: "token-shared" },
|
||||
work: { botToken: "token-shared" },
|
||||
ops: { botToken: "token-ops" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createStartAccountCtx(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
runtime: RuntimeEnv;
|
||||
}): ChannelGatewayContext<ResolvedTelegramAccount> {
|
||||
const account = telegramPlugin.config.resolveAccount(
|
||||
params.cfg,
|
||||
params.accountId,
|
||||
) as ResolvedTelegramAccount;
|
||||
const snapshot: ChannelAccountSnapshot = {
|
||||
accountId: params.accountId,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: false,
|
||||
};
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
account,
|
||||
cfg: params.cfg,
|
||||
runtime: params.runtime,
|
||||
abortSignal: new AbortController().signal,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
getStatus: () => snapshot,
|
||||
setStatus: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("telegramPlugin duplicate token guard", () => {
|
||||
it("marks secondary account as not configured when token is shared", async () => {
|
||||
const cfg = createCfg();
|
||||
const alertsAccount = telegramPlugin.config.resolveAccount(cfg, "alerts");
|
||||
const workAccount = telegramPlugin.config.resolveAccount(cfg, "work");
|
||||
const opsAccount = telegramPlugin.config.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 = telegramPlugin.config.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 = vi.fn(async () => undefined);
|
||||
const probeTelegram = vi.fn(async () => ({ ok: true, bot: { username: "bot" } }));
|
||||
const runtime = {
|
||||
channel: {
|
||||
telegram: {
|
||||
monitorTelegramProvider,
|
||||
probeTelegram,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
setTelegramRuntime(runtime);
|
||||
|
||||
await expect(
|
||||
telegramPlugin.gateway!.startAccount!(
|
||||
createStartAccountCtx({
|
||||
cfg: createCfg(),
|
||||
accountId: "work",
|
||||
runtime: createRuntimeEnv(),
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("Duplicate Telegram bot token");
|
||||
|
||||
expect(probeTelegram).not.toHaveBeenCalled();
|
||||
expect(monitorTelegramProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,40 @@ import { getTelegramRuntime } from "./runtime.js";
|
||||
|
||||
const meta = getChatChannelMeta("telegram");
|
||||
|
||||
function findTelegramTokenOwnerAccountId(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): string | null {
|
||||
const normalizedAccountId = normalizeAccountId(params.accountId);
|
||||
const tokenOwners = new Map<string, string>();
|
||||
for (const id of listTelegramAccountIds(params.cfg)) {
|
||||
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: id });
|
||||
const token = account.token.trim();
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
const ownerAccountId = tokenOwners.get(token);
|
||||
if (!ownerAccountId) {
|
||||
tokenOwners.set(token, account.accountId);
|
||||
continue;
|
||||
}
|
||||
if (account.accountId === normalizedAccountId) {
|
||||
return ownerAccountId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDuplicateTelegramTokenReason(params: {
|
||||
accountId: string;
|
||||
ownerAccountId: string;
|
||||
}): string {
|
||||
return (
|
||||
`Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` +
|
||||
`account "${params.ownerAccountId}". Keep one owner account per bot token.`
|
||||
);
|
||||
}
|
||||
|
||||
const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: (ctx) =>
|
||||
getTelegramRuntime().channel.telegram.messageActions?.listActions?.(ctx) ?? [],
|
||||
@@ -101,12 +135,32 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
accountId,
|
||||
clearBaseFields: ["botToken", "tokenFile", "name"],
|
||||
}),
|
||||
isConfigured: (account) => Boolean(account.token?.trim()),
|
||||
describeAccount: (account) => ({
|
||||
isConfigured: (account, cfg) => {
|
||||
if (!account.token?.trim()) {
|
||||
return false;
|
||||
}
|
||||
return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId });
|
||||
},
|
||||
unconfiguredReason: (account, cfg) => {
|
||||
if (!account.token?.trim()) {
|
||||
return "not configured";
|
||||
}
|
||||
const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId });
|
||||
if (!ownerAccountId) {
|
||||
return "not configured";
|
||||
}
|
||||
return formatDuplicateTelegramTokenReason({
|
||||
accountId: account.accountId,
|
||||
ownerAccountId,
|
||||
});
|
||||
},
|
||||
describeAccount: (account, cfg) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.token?.trim()),
|
||||
configured:
|
||||
Boolean(account.token?.trim()) &&
|
||||
!findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }),
|
||||
tokenSource: account.tokenSource,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
@@ -350,7 +404,17 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups };
|
||||
},
|
||||
buildAccountSnapshot: ({ account, cfg, runtime, probe, audit }) => {
|
||||
const configured = Boolean(account.token?.trim());
|
||||
const ownerAccountId = findTelegramTokenOwnerAccountId({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const duplicateTokenReason = ownerAccountId
|
||||
? formatDuplicateTelegramTokenReason({
|
||||
accountId: account.accountId,
|
||||
ownerAccountId,
|
||||
})
|
||||
: null;
|
||||
const configured = Boolean(account.token?.trim()) && !ownerAccountId;
|
||||
const groups =
|
||||
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
|
||||
cfg.channels?.telegram?.groups;
|
||||
@@ -368,7 +432,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
lastError: runtime?.lastError ?? duplicateTokenReason,
|
||||
mode: runtime?.mode ?? (account.config.webhookUrl ? "webhook" : "polling"),
|
||||
probe,
|
||||
audit,
|
||||
@@ -381,6 +445,18 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const ownerAccountId = findTelegramTokenOwnerAccountId({
|
||||
cfg: ctx.cfg,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
if (ownerAccountId) {
|
||||
const reason = formatDuplicateTelegramTokenReason({
|
||||
accountId: account.accountId,
|
||||
ownerAccountId,
|
||||
});
|
||||
ctx.log?.error?.(`[${account.accountId}] ${reason}`);
|
||||
throw new Error(reason);
|
||||
}
|
||||
const token = account.token.trim();
|
||||
let telegramBotLabel = "";
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user