fix(telegram): guard duplicate bot token accounts

This commit is contained in:
Peter Steinberger
2026-02-21 15:40:38 +01:00
parent b520e7ac38
commit 1bd3f01c17
3 changed files with 207 additions and 5 deletions

View File

@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Security/Agents: cap embedded Pi runner outer retry loop to 24 attempts and return an explicit `retry_limit` error payload when retries never converge, preventing unbounded internal retry cycles (`GHSA-76m6-pj3w-v7mf`).
- Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless `getUpdates` conflict loops.
- Agents/Tool images: include source filenames in `agents/tool-images` resize logs so compression events can be traced back to specific files.
- Providers/OAuth: harden Qwen and Chutes refresh handling by validating refresh response expiry values and preserving prior refresh tokens when providers return empty refresh token fields, with regression coverage for empty-token responses.
- Models/Kimi-Coding: add missing implicit provider template for `kimi-coding` with correct `anthropic-messages` API type and base URL, fixing 403 errors when using Kimi for Coding. (#22409)

View 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();
});
});

View File

@@ -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 {