diff --git a/extensions/telegram/src/account-config.ts b/extensions/telegram/src/account-config.ts index 76fb538c8ad0..52cbe11734ce 100644 --- a/extensions/telegram/src/account-config.ts +++ b/extensions/telegram/src/account-config.ts @@ -1,6 +1,6 @@ import { normalizeAccountId, - resolveAccountEntry, + resolveNormalizedAccountEntry, type OpenClawConfig, } from "openclaw/plugin-sdk/account-core"; import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; @@ -49,7 +49,11 @@ export function resolveTelegramAccountConfig( accountId: string, ): TelegramAccountConfig | undefined { const normalized = normalizeAccountId(accountId); - return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalized); + return resolveNormalizedAccountEntry( + cfg.channels?.telegram?.accounts, + normalized, + normalizeAccountId, + ); } export function mergeTelegramAccountConfig( diff --git a/extensions/telegram/src/account-inspect.test.ts b/extensions/telegram/src/account-inspect.test.ts index f31c40bebd79..5031c352398a 100644 --- a/extensions/telegram/src/account-inspect.test.ts +++ b/extensions/telegram/src/account-inspect.test.ts @@ -80,6 +80,52 @@ describe("inspectTelegramAccount SecretRef resolution", () => { }); }); + it("matches runtime token lookup for account keys that need full normalization", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { + "Carey Notifications": { + botToken: "123:token", + reactionLevel: "ack", + }, + }, + }, + }, + }; + + const account = inspectTelegramAccount({ + cfg, + accountId: "carey-notifications", + }); + + expect(account.accountId).toBe("carey-notifications"); + expect(account.configured).toBe(true); + expect(account.tokenSource).toBe("config"); + expect(account.tokenStatus).toBe("available"); + expect(account.config.reactionLevel).toBe("ack"); + }); + + it("blocks channel-token fallback for unknown scoped accounts in multi-account config", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + botToken: "123:channel", + accounts: { + work: { botToken: "123:work" }, + }, + }, + }, + }; + + const account = inspectTelegramAccount({ cfg, accountId: "unknown" }); + + expect(account.accountId).toBe("unknown"); + expect(account.configured).toBe(false); + expect(account.tokenSource).toBe("none"); + expect(account.tokenStatus).toBe("missing"); + }); + it.runIf(process.platform !== "win32")( "treats symlinked token files as configured_unavailable", () => { diff --git a/extensions/telegram/src/account-inspect.ts b/extensions/telegram/src/account-inspect.ts index 878796df3459..974bd3c0f3cf 100644 --- a/extensions/telegram/src/account-inspect.ts +++ b/extensions/telegram/src/account-inspect.ts @@ -130,6 +130,16 @@ function inspectTokenValue(params: { cfg: OpenClawConfig; value: unknown }): { return null; } +function hasConfiguredTelegramAccounts(cfg: OpenClawConfig): boolean { + const accounts = cfg.channels?.telegram?.accounts; + return ( + !!accounts && + typeof accounts === "object" && + !Array.isArray(accounts) && + Object.keys(accounts).length > 0 + ); +} + function inspectTelegramAccountPrimary(params: { cfg: OpenClawConfig; accountId: string; @@ -140,6 +150,10 @@ function inspectTelegramAccountPrimary(params: { const enabled = params.cfg.channels?.telegram?.enabled !== false && merged.enabled !== false; const accountConfig = resolveTelegramAccountConfig(params.cfg, accountId); + const allowChannelCredentialFallback = + accountId === DEFAULT_ACCOUNT_ID || + !!accountConfig || + !hasConfiguredTelegramAccounts(params.cfg); const accountTokenFile = inspectTokenFile(accountConfig?.tokenFile); if (accountTokenFile) { return { @@ -168,35 +182,37 @@ function inspectTelegramAccountPrimary(params: { }; } - const channelTokenFile = inspectTokenFile(params.cfg.channels?.telegram?.tokenFile); - if (channelTokenFile) { - return { - accountId, - enabled, - name: normalizeOptionalString(merged.name), - token: channelTokenFile.token, - tokenSource: channelTokenFile.tokenSource, - tokenStatus: channelTokenFile.tokenStatus, - configured: channelTokenFile.tokenStatus !== "missing", - config: merged, - }; - } + if (allowChannelCredentialFallback) { + const channelTokenFile = inspectTokenFile(params.cfg.channels?.telegram?.tokenFile); + if (channelTokenFile) { + return { + accountId, + enabled, + name: normalizeOptionalString(merged.name), + token: channelTokenFile.token, + tokenSource: channelTokenFile.tokenSource, + tokenStatus: channelTokenFile.tokenStatus, + configured: channelTokenFile.tokenStatus !== "missing", + config: merged, + }; + } - const channelToken = inspectTokenValue({ - cfg: params.cfg, - value: params.cfg.channels?.telegram?.botToken, - }); - if (channelToken) { - return { - accountId, - enabled, - name: normalizeOptionalString(merged.name), - token: channelToken.token, - tokenSource: channelToken.tokenSource, - tokenStatus: channelToken.tokenStatus, - configured: channelToken.tokenStatus !== "missing", - config: merged, - }; + const channelToken = inspectTokenValue({ + cfg: params.cfg, + value: params.cfg.channels?.telegram?.botToken, + }); + if (channelToken) { + return { + accountId, + enabled, + name: normalizeOptionalString(merged.name), + token: channelToken.token, + tokenSource: channelToken.tokenSource, + tokenStatus: channelToken.tokenStatus, + configured: channelToken.tokenStatus !== "missing", + config: merged, + }; + } } const allowEnv = accountId === DEFAULT_ACCOUNT_ID; diff --git a/extensions/telegram/src/channel-actions.test.ts b/extensions/telegram/src/channel-actions.test.ts index edb13c283972..c08295d09f08 100644 --- a/extensions/telegram/src/channel-actions.test.ts +++ b/extensions/telegram/src/channel-actions.test.ts @@ -316,4 +316,132 @@ describe("telegramMessageActions", () => { } } }); + + // Regression for #75433: prompt discovery reads raw config before the active + // runtime snapshot has resolved SecretRefs. Treat SecretRef-backed accounts + // as configured and keep advertising config-derived actions. + it("describes discovery when botToken is an unresolved SecretRef instead of crashing the embedded run", () => { + const cfg = { + channels: { + telegram: { + botToken: { source: "exec", provider: "default", id: "telegram-token" }, + actions: { + reactions: true, + poll: false, + }, + }, + }, + } as unknown as OpenClawConfig; + + const discovery = telegramMessageActions.describeMessageTool?.({ cfg }); + + expect(discovery?.actions).toContain("send"); + expect(discovery?.actions).toContain("react"); + expect(discovery?.actions).not.toContain("poll"); + }); + + it("describes scoped account discovery when Telegram account token is an unresolved SecretRef", () => { + const cfg = { + channels: { + telegram: { + accounts: { + ops: { + botToken: { source: "exec", provider: "default", id: "telegram-ops" }, + actions: { + reactions: false, + poll: true, + }, + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + const discovery = telegramMessageActions.describeMessageTool?.({ + cfg, + accountId: "ops", + }); + + expect(discovery?.actions).toContain("send"); + expect(discovery?.actions).toContain("poll"); + expect(discovery?.actions).not.toContain("react"); + }); + + it("matches runtime account-key normalization during SecretRef-tolerant discovery", () => { + const cfg = { + channels: { + telegram: { + accounts: { + "Carey Notifications": { + botToken: { source: "exec", provider: "default", id: "telegram-carey" }, + actions: { + poll: true, + reactions: false, + }, + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + const discovery = telegramMessageActions.describeMessageTool?.({ + cfg, + accountId: "carey-notifications", + }); + + expect(discovery?.actions).toContain("send"); + expect(discovery?.actions).toContain("poll"); + expect(discovery?.actions).not.toContain("react"); + }); + + it("does not discover unknown scoped accounts via channel-level fallback in multi-account config", () => { + const cfg = { + channels: { + telegram: { + botToken: "tok-channel", + accounts: { + work: { botToken: "tok-work" }, + }, + }, + }, + } as OpenClawConfig; + + expect( + telegramMessageActions.describeMessageTool?.({ + cfg, + accountId: "unknown", + })?.actions, + ).toEqual([]); + }); + + it("keeps healthy Telegram accounts discoverable when a sibling token is an unresolved SecretRef", () => { + const cfg = { + channels: { + telegram: { + accounts: { + unresolved: { + botToken: { source: "exec", provider: "default", id: "telegram-unresolved" }, + actions: { + reactions: false, + poll: false, + }, + }, + healthy: { + botToken: "tok-healthy", + actions: { + reactions: true, + poll: false, + }, + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + const discovery = telegramMessageActions.describeMessageTool?.({ cfg }); + + expect(discovery?.actions).toContain("send"); + expect(discovery?.actions).toContain("react"); + expect(discovery?.actions).not.toContain("poll"); + }); }); diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index 27b24966b4d9..cdb9e219a5ef 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -12,10 +12,10 @@ import type { import type { TelegramActionConfig } from "openclaw/plugin-sdk/config-contracts"; import { readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; import { extractToolSend } from "openclaw/plugin-sdk/tool-send"; +import { inspectTelegramAccount } from "./account-inspect.js"; import { createTelegramActionGate, - listEnabledTelegramAccounts, - resolveTelegramAccount, + listTelegramAccountIds, resolveTelegramPollActionGateState, } from "./accounts.js"; import { isTelegramInlineButtonsEnabled } from "./inline-buttons.js"; @@ -53,8 +53,11 @@ function resolveTelegramMessageActionName(action: ChannelMessageActionName) { return TELEGRAM_MESSAGE_ACTION_MAP[action as keyof typeof TELEGRAM_MESSAGE_ACTION_MAP]; } -function resolveTelegramActionDiscovery(cfg: Parameters[0]) { - const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg)); +function resolveTelegramActionDiscovery(cfg: Parameters[0]) { + const inspected = listTelegramAccountIds(cfg) + .map((accountId) => inspectTelegramAccount({ cfg, accountId })) + .filter((account) => account.enabled && account.configured); + const accounts = listTokenSourcedAccounts(inspected); if (accounts.length === 0) { return null; } @@ -83,14 +86,14 @@ function resolveTelegramActionDiscovery(cfg: Parameters[0]; + cfg: Parameters[0]; accountId?: string | null; }) { if (!params.accountId) { return resolveTelegramActionDiscovery(params.cfg); } - const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId }); - if (!account.enabled || account.tokenSource === "none") { + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId }); + if (!account.enabled || !account.configured || account.tokenSource === "none") { return null; } const gate = createTelegramActionGate({ diff --git a/extensions/telegram/src/inline-buttons.test.ts b/extensions/telegram/src/inline-buttons.test.ts index 2dd842a76715..4ef8a6e99d33 100644 --- a/extensions/telegram/src/inline-buttons.test.ts +++ b/extensions/telegram/src/inline-buttons.test.ts @@ -1,7 +1,12 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { describe, expect, it } from "vitest"; import { buildTelegramInteractiveButtons } from "./button-types.js"; import { describeTelegramInteractiveButtonBehavior } from "./button-types.test-helpers.js"; -import { resolveTelegramTargetChatType } from "./inline-buttons.js"; +import { + isTelegramInlineButtonsEnabled, + resolveTelegramInlineButtonsScope, + resolveTelegramTargetChatType, +} from "./inline-buttons.js"; describe("resolveTelegramTargetChatType", () => { it("returns 'direct' for positive numeric IDs", () => { @@ -85,3 +90,53 @@ describe("buildTelegramInteractiveButtons callback rewrites", () => { ]); }); }); + +describe("resolveTelegramInlineButtonsScope (#75433 SecretRef tolerance)", () => { + // Embedded prompt prep calls this from raw config before the active runtime + // snapshot has resolved channel credentials. Read-only account inspection + // keeps SecretRef-backed config readable without resolving the token. + it("preserves the default inline-buttons scope when botToken is an unresolved SecretRef", () => { + const cfg = { + channels: { + telegram: { + botToken: { source: "exec", provider: "default", id: "telegram-token" }, + }, + }, + } as unknown as OpenClawConfig; + + expect(resolveTelegramInlineButtonsScope({ cfg })).toBe("allowlist"); + expect(isTelegramInlineButtonsEnabled({ cfg })).toBe(true); + }); + + it('preserves configured "off" when botToken is an unresolved SecretRef', () => { + const cfg = { + channels: { + telegram: { + botToken: { source: "exec", provider: "default", id: "telegram-token" }, + capabilities: { inlineButtons: "off" }, + }, + }, + } as unknown as OpenClawConfig; + + expect(resolveTelegramInlineButtonsScope({ cfg })).toBe("off"); + expect(isTelegramInlineButtonsEnabled({ cfg })).toBe(false); + }); + + it("preserves scoped account inline-buttons config when the token is an unresolved SecretRef", () => { + const cfg = { + channels: { + telegram: { + accounts: { + ops: { + botToken: { source: "exec", provider: "default", id: "telegram-ops" }, + capabilities: { inlineButtons: "all" }, + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + expect(resolveTelegramInlineButtonsScope({ cfg, accountId: "ops" })).toBe("all"); + expect(isTelegramInlineButtonsEnabled({ cfg, accountId: "ops" })).toBe(true); + }); +}); diff --git a/extensions/telegram/src/inline-buttons.ts b/extensions/telegram/src/inline-buttons.ts index 7f03f5064a21..4d2743f28ffc 100644 --- a/extensions/telegram/src/inline-buttons.ts +++ b/extensions/telegram/src/inline-buttons.ts @@ -4,7 +4,8 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js"; +import { inspectTelegramAccount } from "./account-inspect.js"; +import { listTelegramAccountIds } from "./accounts.js"; const DEFAULT_INLINE_BUTTONS_SCOPE: TelegramInlineButtonsScope = "allowlist"; @@ -60,7 +61,7 @@ export function resolveTelegramInlineButtonsScope(params: { cfg: OpenClawConfig; accountId?: string | null; }): TelegramInlineButtonsScope { - const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId }); + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId }); return resolveTelegramInlineButtonsScopeFromCapabilities(account.config.capabilities); } diff --git a/extensions/telegram/src/reaction-level.test.ts b/extensions/telegram/src/reaction-level.test.ts index 367793d82c60..575e27a9a4d2 100644 --- a/extensions/telegram/src/reaction-level.test.ts +++ b/extensions/telegram/src/reaction-level.test.ts @@ -136,4 +136,49 @@ describe("resolveTelegramReactionLevel", () => { const result = resolveTelegramReactionLevel({ cfg, accountId: "work" }); expectMinimalFlags(result); }); + + // Regression for #75433: prompt-prep reaction guidance reads raw config + // before the active runtime snapshot has resolved channel credentials. + it("preserves configured reactionLevel when botToken is an unresolved SecretRef", () => { + const cfg = { + channels: { + telegram: { + botToken: { source: "exec", provider: "default", id: "telegram-token" }, + reactionLevel: "off", + }, + }, + } as unknown as OpenClawConfig; + + expect(() => resolveTelegramReactionLevel({ cfg })).not.toThrow(); + const result = resolveTelegramReactionLevel({ cfg }); + expectReactionFlags(result, { + level: "off", + ackEnabled: false, + agentReactionsEnabled: false, + }); + }); + + it("preserves scoped account reactionLevel when token is an unresolved SecretRef", () => { + const cfg = { + channels: { + telegram: { + reactionLevel: "minimal", + accounts: { + ops: { + botToken: { source: "exec", provider: "default", id: "telegram-ops" }, + reactionLevel: "ack", + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + expect(() => resolveTelegramReactionLevel({ cfg, accountId: "ops" })).not.toThrow(); + const result = resolveTelegramReactionLevel({ cfg, accountId: "ops" }); + expectReactionFlags(result, { + level: "ack", + ackEnabled: true, + agentReactionsEnabled: false, + }); + }); }); diff --git a/extensions/telegram/src/reaction-level.ts b/extensions/telegram/src/reaction-level.ts index fb4e5484baf0..9f69faec4f2c 100644 --- a/extensions/telegram/src/reaction-level.ts +++ b/extensions/telegram/src/reaction-level.ts @@ -4,7 +4,7 @@ import { type ReactionLevel, type ResolvedReactionLevel as BaseResolvedReactionLevel, } from "openclaw/plugin-sdk/status-helpers"; -import { resolveTelegramAccount } from "./accounts.js"; +import { inspectTelegramAccount } from "./account-inspect.js"; export type TelegramReactionLevel = ReactionLevel; export type ResolvedReactionLevel = BaseResolvedReactionLevel; @@ -16,7 +16,7 @@ export function resolveTelegramReactionLevel(params: { cfg: OpenClawConfig; accountId?: string; }): ResolvedReactionLevel { - const account = resolveTelegramAccount({ + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId, });