fix(channels): preserve Telegram SecretRef prompt config

Use read-only Telegram account inspection for prompt-time channel actions, inline buttons, and reaction guidance so unresolved SecretRef tokens retain configured non-secret behavior before runtime snapshot hydration.

Match runtime Telegram account lookup for normalized config keys and multi-account fallback guards, while keeping sends/actions on the existing strict credential resolution path.

Fixes #75433.

Co-authored-by: Shubhankar Tripathy <reach2shubhankar@gmail.com>
This commit is contained in:
Shubhankar Tripathy
2026-05-27 14:25:41 -05:00
committed by GitHub
parent ee57f341f0
commit 90f30075aa
9 changed files with 340 additions and 42 deletions

View File

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

View File

@@ -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",
() => {

View File

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

View File

@@ -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");
});
});

View File

@@ -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<typeof listEnabledTelegramAccounts>[0]) {
const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg));
function resolveTelegramActionDiscovery(cfg: Parameters<typeof listTelegramAccountIds>[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<typeof listEnabledTelegr
}
function resolveScopedTelegramActionDiscovery(params: {
cfg: Parameters<typeof listEnabledTelegramAccounts>[0];
cfg: Parameters<typeof listTelegramAccountIds>[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({

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -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,
});
});
});

View File

@@ -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,
});