fix: resolve telegram token fallback for binding-created accounts (#54362) (thanks @openperf)

* fix(telegram): resolve channel-level token fallthrough for binding-created accountIds

Fixes #53876

* fix(telegram): align isConfigured with resolveTelegramToken multi-bot guard

* fix(telegram): use normalized account lookup and require available token
This commit is contained in:
wangchunyue
2026-03-26 11:46:15 +08:00
committed by GitHub
parent bc1c308383
commit ebad7490b4
4 changed files with 225 additions and 17 deletions

View File

@@ -476,6 +476,97 @@ describe("telegramPlugin duplicate token guard", () => {
expect(await telegramPlugin.config.isConfigured!(alertsAccount, cfg)).toBe(true);
});
// Regression: https://github.com/openclaw/openclaw/issues/53876
// Single-bot setup with channel-level token should report configured.
it("reports configured for single-bot setup with channel-level token", async () => {
const cfg = {
channels: {
telegram: {
botToken: "single-bot-token",
enabled: true,
},
},
} as OpenClawConfig;
const account = resolveAccount(cfg, "default");
expect(await telegramPlugin.config.isConfigured!(account, cfg)).toBe(true);
});
// Regression: https://github.com/openclaw/openclaw/issues/53876
// Binding-created non-default accountId in single-bot setup should report configured.
it("reports configured for binding-created accountId in single-bot setup", async () => {
const cfg = {
channels: {
telegram: {
botToken: "single-bot-token",
enabled: true,
},
},
} as OpenClawConfig;
const account = resolveAccount(cfg, "bot-main");
expect(account.token).toBe("single-bot-token");
expect(await telegramPlugin.config.isConfigured!(account, cfg)).toBe(true);
});
// Regression: multi-bot guard — unknown binding-created accountId in multi-bot
// setup must NOT be reported as configured, matching resolveTelegramToken behaviour.
it("reports not configured for unknown binding-created accountId in multi-bot setup", async () => {
const cfg = {
channels: {
telegram: {
botToken: "channel-level-token",
enabled: true,
accounts: {
knownBot: { botToken: "known-bot-token" },
},
},
},
} as OpenClawConfig;
const account = resolveAccount(cfg, "unknownBot");
expect(await telegramPlugin.config.isConfigured!(account, cfg)).toBe(false);
expect(telegramPlugin.config.unconfiguredReason?.(account, cfg)).toContain("unknown accountId");
});
// Regression: multi-bot guard must use full normalization (same as resolveTelegramToken)
// so that account keys like "Carey Notifications" resolve to "carey-notifications".
it("multi-bot guard normalizes account keys with spaces and mixed case", async () => {
const cfg = {
channels: {
telegram: {
botToken: "channel-level-token",
enabled: true,
accounts: {
"Carey Notifications": { botToken: "carey-token" },
},
},
},
} as OpenClawConfig;
// "carey-notifications" is the normalized form of "Carey Notifications"
const account = resolveAccount(cfg, "carey-notifications");
expect(await telegramPlugin.config.isConfigured!(account, cfg)).toBe(true);
});
// Regression: configured_unavailable token (e.g. unreadable tokenFile) should
// NOT be reported as configured — runtime would fail to authenticate.
it("reports not configured when token is configured_unavailable", async () => {
const cfg = {
channels: {
telegram: {
tokenFile: "/nonexistent/path/to/token",
enabled: true,
},
},
} as OpenClawConfig;
const account = resolveAccount(cfg, "default");
// tokenFile is configured but file doesn't exist → configured_unavailable
expect(await telegramPlugin.config.isConfigured!(account, cfg)).toBe(false);
expect(telegramPlugin.config.unconfiguredReason?.(account, cfg)).toContain("unavailable");
});
it("does not crash startup when a resolved account token is undefined", async () => {
const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({
probeOk: false,

View File

@@ -1,9 +1,11 @@
import { resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution";
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import {
buildChannelConfigSchema,
getChatChannelMeta,
@@ -56,6 +58,39 @@ export function formatDuplicateTelegramTokenReason(params: {
);
}
/**
* Returns true when the runtime token resolver (`resolveTelegramToken`) would
* block channel-level fallthrough for the given accountId. This mirrors the
* guard in `token.ts` so that status-check functions (`isConfigured`,
* `unconfiguredReason`, `describeAccount`) stay consistent with the gateway
* runtime behaviour.
*
* The guard fires when:
* 1. The accountId is not the default account, AND
* 2. The config has an explicit `accounts` section with entries, AND
* 3. The accountId is not found in that `accounts` section.
*
* See: https://github.com/openclaw/openclaw/issues/53876
*/
function isBlockedByMultiBotGuard(cfg: OpenClawConfig, accountId: string): boolean {
if (normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID) {
return false;
}
const accounts = cfg.channels?.telegram?.accounts;
const hasConfiguredAccounts =
!!accounts &&
typeof accounts === "object" &&
!Array.isArray(accounts) &&
Object.keys(accounts).length > 0;
if (!hasConfiguredAccounts) {
return false;
}
// Use resolveNormalizedAccountEntry (same as resolveTelegramToken in token.ts)
// instead of resolveAccountEntry to handle keys that require full normalization
// (e.g. "Carey Notifications" → "carey-notifications").
return !resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
}
export const telegramConfigAdapter = createScopedChannelConfigAdapter<ResolvedTelegramAccount>({
sectionKey: TELEGRAM_CHANNEL,
listAccountIds: listTelegramAccountIds,
@@ -97,13 +132,32 @@ export function createTelegramPluginBase(params: {
config: {
...telegramConfigAdapter,
isConfigured: (account, cfg) => {
if (!account.token?.trim()) {
// Use inspectTelegramAccount for a complete token resolution that includes
// channel-level fallback paths not available in resolveTelegramAccount.
// This ensures binding-created accountIds that inherit the channel-level
// token are correctly detected as configured.
// See: https://github.com/openclaw/openclaw/issues/53876
if (isBlockedByMultiBotGuard(cfg, account.accountId)) {
return false;
}
const inspected = inspectTelegramAccount({ cfg, accountId: account.accountId });
// Gate on actually available token, not just "configured" — the latter
// includes "configured_unavailable" (unreadable tokenFile, unresolved
// SecretRef) which would pass here but fail at runtime.
if (!inspected.token?.trim()) {
return false;
}
return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId });
},
unconfiguredReason: (account, cfg) => {
if (!account.token?.trim()) {
if (isBlockedByMultiBotGuard(cfg, account.accountId)) {
return `not configured: unknown accountId "${account.accountId}" in multi-bot setup`;
}
const inspected = inspectTelegramAccount({ cfg, accountId: account.accountId });
if (!inspected.token?.trim()) {
if (inspected.tokenStatus === "configured_unavailable") {
return `not configured: token ${inspected.tokenSource} is configured but unavailable`;
}
return "not configured";
}
const ownerAccountId = findTelegramTokenOwnerAccountId({
@@ -118,15 +172,27 @@ export function createTelegramPluginBase(params: {
ownerAccountId,
});
},
describeAccount: (account, cfg) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured:
Boolean(account.token?.trim()) &&
!findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }),
tokenSource: account.tokenSource,
}),
describeAccount: (account, cfg) => {
if (isBlockedByMultiBotGuard(cfg, account.accountId)) {
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: false,
tokenSource: "none" as const,
};
}
const inspected = inspectTelegramAccount({ cfg, accountId: account.accountId });
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured:
!!inspected.token?.trim() &&
!findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }),
tokenSource: inspected.tokenSource,
};
},
},
setup: params.setup,
}) as Pick<

View File

@@ -236,6 +236,42 @@ describe("resolveTelegramToken", () => {
/channels\.telegram\.botToken: unresolved SecretRef/i,
);
});
// Regression: https://github.com/openclaw/openclaw/issues/53876
// Binding-created accountIds should inherit the channel-level token in
// single-bot setups (no accounts section).
it("falls through to channel-level token for binding-created accountId without accounts section", () => {
const cfg = {
channels: {
telegram: {
botToken: "channel-level-token",
enabled: true,
},
},
} as OpenClawConfig;
const res = resolveTelegramToken(cfg, { accountId: "bot-main" });
expect(res.token).toBe("channel-level-token");
expect(res.source).toBe("config");
});
it("still blocks fallthrough for unknown accountId when accounts section exists", () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
const cfg = {
channels: {
telegram: {
botToken: "wrong-bot-token",
accounts: {
knownBot: { botToken: "known-bot-token" },
},
},
},
} as OpenClawConfig;
const res = resolveTelegramToken(cfg, { accountId: "unknownBot" });
expect(res.token).toBe("");
expect(res.source).toBe("none");
});
});
describe("telegram update offset store", () => {

View File

@@ -39,13 +39,28 @@ export function resolveTelegramToken(
);
// When a non-default accountId is explicitly specified but not found in config,
// return empty immediately — do NOT fall through to channel-level defaults,
// which would silently route the message via the wrong bot's token.
// decide whether to fall through to channel-level defaults based on whether
// the config has an explicit accounts section (multi-bot setup).
//
// Multi-bot: accounts section exists with entries → block fallthrough to prevent
// routing via the wrong bot's token.
//
// Single-bot: no accounts section (or empty) → allow fallthrough so that
// binding-created accountIds inherit the channel-level token.
// See: https://github.com/openclaw/openclaw/issues/53876
if (accountId !== DEFAULT_ACCOUNT_ID && !accountCfg) {
opts.logMissingFile?.(
`channels.telegram.accounts: unknown accountId "${accountId}" — not found in config, refusing channel-level fallback`,
);
return { token: "", source: "none" };
const accounts = telegramCfg?.accounts;
const hasConfiguredAccounts =
!!accounts &&
typeof accounts === "object" &&
!Array.isArray(accounts) &&
Object.keys(accounts).length > 0;
if (hasConfiguredAccounts) {
opts.logMissingFile?.(
`channels.telegram.accounts: unknown accountId "${accountId}" — not found in config, refusing channel-level fallback`,
);
return { token: "", source: "none" };
}
}
const accountTokenFile = accountCfg?.tokenFile?.trim();