diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fb373429b6..6bc99314225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Typing/Cross-channel leakage: unify run-scoped typing suppression for cross-channel/internal-webchat routes, preserve current inbound origin as embedded run message channel context, harden shared typing keepalive with consecutive-failure circuit breaker edge-case handling, and enforce dispatcher completion/idle waits in extension dispatcher callsites (Feishu, Matrix, Mattermost, MSTeams) so typing indicators always clean up on success/error paths. Related: #27647, #27493, #27598. Supersedes/replaces draft PRs: #27640, #27593, #27540. - Telegram/sendChatAction 401 handling: add bounded exponential backoff + temporary local typing suppression after repeated unauthorized failures to stop unbounded `sendChatAction` retry loops that can trigger Telegram abuse enforcement and bot deletion. (#27415) Thanks @widingmarcus-cyber. - Telegram/Webhook startup: clarify webhook config guidance, allow `channels.telegram.webhookPort: 0` for ephemeral listener binding, and log both the local listener URL and Telegram-advertised webhook URL with the bound port. (#25732) thanks @huntharo. +- Config/Doctor allowlist safety: reject `dmPolicy: "allowlist"` configs with empty `allowFrom`, add Telegram account-level inheritance-aware validation, and teach `openclaw doctor --fix` to restore missing `allowFrom` entries from pairing-store files when present, preventing silent DM drops after upgrades. (#27936) Thanks @widingmarcus-cyber. - Browser/Chrome extension handshake: bind relay WS message handling before `onopen` and add non-blocking `connect.challenge` response handling for gateway-style handshake frames, avoiding stuck `…` badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553) - Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688) - Browser/Fill relay + CLI parity: accept `act.fill` fields without explicit `type` by defaulting missing/empty `type` to `text` in both browser relay route parsing and `openclaw browser fill` CLI field parsing, so relay calls no longer fail when the model omits field type metadata. Landed from contributor PR #27662 by @Uface11. (#27296) Thanks @Uface11. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index a4713d9c027..7313ef2b5fc 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -109,13 +109,15 @@ Token resolution order is account-aware. In practice, config values win over env `channels.telegram.dmPolicy` controls direct message access: - `pairing` (default) - - `allowlist` + - `allowlist` (requires at least one sender ID in `allowFrom`) - `open` (requires `allowFrom` to include `"*"`) - `disabled` `channels.telegram.allowFrom` accepts numeric Telegram user IDs. `telegram:` / `tg:` prefixes are accepted and normalized. + `dmPolicy: "allowlist"` with empty `allowFrom` blocks all DMs and is rejected by config validation. The onboarding wizard accepts `@username` input and resolves it to numeric IDs. If you upgraded and your config contains `@username` allowlist entries, run `openclaw doctor --fix` to resolve them (best-effort; requires a Telegram bot token). + If you previously relied on pairing-store allowlist files, `openclaw doctor --fix` can auto-migrate recovered entries into `channels.telegram.allowFrom`. ### Finding your Telegram user ID @@ -716,7 +718,7 @@ Primary reference: - `channels.telegram.botToken`: bot token (BotFather). - `channels.telegram.tokenFile`: read token from file path. - `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). -- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. +- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `allowlist` requires at least one sender ID. `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs and can restore allowlist entries from pairing-store files when available. - `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist). - `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. - Multi-account precedence: diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 0618e234493..d4b0327397d 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -452,6 +452,50 @@ describe("doctor config flow", () => { expect(cfg.channels.discord.accounts.work.allowFrom).toEqual(["*"]); }); + it('repairs dmPolicy="allowlist" by restoring allowFrom from pairing store on repair', async () => { + const result = await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + const credentialsDir = path.join(configDir, "credentials"); + await fs.mkdir(credentialsDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify( + { + channels: { + telegram: { + botToken: "fake-token", + dmPolicy: "allowlist", + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + await fs.writeFile( + path.join(credentialsDir, "telegram-allowFrom.json"), + JSON.stringify({ version: 1, allowFrom: ["12345"] }, null, 2), + "utf-8", + ); + return await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true, repair: true }, + confirm: async () => false, + }); + }); + + const cfg = result.cfg as { + channels: { + telegram: { + dmPolicy: string; + allowFrom: string[]; + }; + }; + }; + expect(cfg.channels.telegram.dmPolicy).toBe("allowlist"); + expect(cfg.channels.telegram.allowFrom).toEqual(["12345"]); + }); + it("migrates legacy toolsBySender keys to typed id entries on repair", async () => { const result = await runDoctorConfigWithInput({ repair: true, diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 5d3ee6cf47e..5c62a8c2516 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -28,6 +28,7 @@ import { isTrustedSafeBinPath, normalizeTrustedSafeBinDirs, } from "../infra/exec-safe-bin-trust.js"; +import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { isDiscordMutableAllowEntry, @@ -1095,10 +1096,167 @@ function maybeRepairOpenPolicyAllowFrom(cfg: OpenClawConfig): { return { config: next, changes }; } +function hasAllowFromEntries(list?: Array) { + return Array.isArray(list) && list.map((v) => String(v).trim()).filter(Boolean).length > 0; +} + +async function maybeRepairAllowlistPolicyAllowFrom(cfg: OpenClawConfig): Promise<{ + config: OpenClawConfig; + changes: string[]; +}> { + const channels = cfg.channels; + if (!channels || typeof channels !== "object") { + return { config: cfg, changes: [] }; + } + + type AllowFromMode = "topOnly" | "topOrNested" | "nestedOnly"; + + const resolveAllowFromMode = (channelName: string): AllowFromMode => { + if (channelName === "googlechat") { + return "nestedOnly"; + } + if (channelName === "discord" || channelName === "slack") { + return "topOrNested"; + } + return "topOnly"; + }; + + const next = structuredClone(cfg); + const changes: string[] = []; + + const applyRecoveredAllowFrom = (params: { + account: Record; + allowFrom: string[]; + mode: AllowFromMode; + prefix: string; + }) => { + const count = params.allowFrom.length; + const noun = count === 1 ? "entry" : "entries"; + + if (params.mode === "nestedOnly") { + const dmEntry = params.account.dm; + const dm = + dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry) + ? (dmEntry as Record) + : {}; + dm.allowFrom = params.allowFrom; + params.account.dm = dm; + changes.push( + `- ${params.prefix}.dm.allowFrom: restored ${count} sender ${noun} from pairing store (dmPolicy="allowlist").`, + ); + return; + } + + if (params.mode === "topOrNested") { + const dmEntry = params.account.dm; + const dm = + dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry) + ? (dmEntry as Record) + : undefined; + const nestedAllowFrom = dm?.allowFrom as Array | undefined; + if (dm && !Array.isArray(params.account.allowFrom) && Array.isArray(nestedAllowFrom)) { + dm.allowFrom = params.allowFrom; + changes.push( + `- ${params.prefix}.dm.allowFrom: restored ${count} sender ${noun} from pairing store (dmPolicy="allowlist").`, + ); + return; + } + } + + params.account.allowFrom = params.allowFrom; + changes.push( + `- ${params.prefix}.allowFrom: restored ${count} sender ${noun} from pairing store (dmPolicy="allowlist").`, + ); + }; + + const recoverAllowFromForAccount = async (params: { + channelName: string; + account: Record; + accountId?: string; + prefix: string; + }) => { + const dmEntry = params.account.dm; + const dm = + dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry) + ? (dmEntry as Record) + : undefined; + const dmPolicy = + (params.account.dmPolicy as string | undefined) ?? (dm?.policy as string | undefined); + if (dmPolicy !== "allowlist") { + return; + } + + const topAllowFrom = params.account.allowFrom as Array | undefined; + const nestedAllowFrom = dm?.allowFrom as Array | undefined; + if (hasAllowFromEntries(topAllowFrom) || hasAllowFromEntries(nestedAllowFrom)) { + return; + } + + const normalizedChannelId = (normalizeChatChannelId(params.channelName) ?? params.channelName) + .trim() + .toLowerCase(); + if (!normalizedChannelId) { + return; + } + const normalizedAccountId = normalizeAccountId(params.accountId) || DEFAULT_ACCOUNT_ID; + const fromStore = await readChannelAllowFromStore( + normalizedChannelId, + process.env, + normalizedAccountId, + ).catch(() => []); + const recovered = Array.from(new Set(fromStore.map((entry) => String(entry).trim()))).filter( + Boolean, + ); + if (recovered.length === 0) { + return; + } + + applyRecoveredAllowFrom({ + account: params.account, + allowFrom: recovered, + mode: resolveAllowFromMode(params.channelName), + prefix: params.prefix, + }); + }; + + const nextChannels = next.channels as Record>; + for (const [channelName, channelConfig] of Object.entries(nextChannels)) { + if (!channelConfig || typeof channelConfig !== "object") { + continue; + } + await recoverAllowFromForAccount({ + channelName, + account: channelConfig, + prefix: `channels.${channelName}`, + }); + + const accounts = channelConfig.accounts as Record> | undefined; + if (!accounts || typeof accounts !== "object") { + continue; + } + for (const [accountId, accountConfig] of Object.entries(accounts)) { + if (!accountConfig || typeof accountConfig !== "object") { + continue; + } + await recoverAllowFromForAccount({ + channelName, + account: accountConfig, + accountId, + prefix: `channels.${channelName}.accounts.${accountId}`, + }); + } + } + + if (changes.length === 0) { + return { config: cfg, changes: [] }; + } + return { config: next, changes }; +} + /** * Scan all channel configs for dmPolicy="allowlist" without any allowFrom entries. - * This configuration causes all DMs to be silently dropped because no sender can - * match the empty allowlist. Common after upgrades that remove external allowlist + * This configuration blocks all DMs because no sender can match the empty + * allowlist. Common after upgrades that remove external allowlist * file support. */ function detectEmptyAllowlistPolicy(cfg: OpenClawConfig): string[] { @@ -1109,9 +1267,6 @@ function detectEmptyAllowlistPolicy(cfg: OpenClawConfig): string[] { const warnings: string[] = []; - const hasEntries = (list?: Array) => - Array.isArray(list) && list.map((v) => String(v).trim()).filter(Boolean).length > 0; - const checkAccount = ( account: Record, prefix: string, @@ -1145,12 +1300,12 @@ function detectEmptyAllowlistPolicy(cfg: OpenClawConfig): string[] { const parentNestedAllowFrom = parentDm?.allowFrom as Array | undefined; const effectiveAllowFrom = topAllowFrom ?? nestedAllowFrom ?? parentNestedAllowFrom; - if (hasEntries(effectiveAllowFrom)) { + if (hasAllowFromEntries(effectiveAllowFrom)) { return; } warnings.push( - `- ${prefix}.dmPolicy is "allowlist" but allowFrom is empty — all DMs will be silently dropped. Add sender IDs to ${prefix}.allowFrom or change dmPolicy to "pairing".`, + `- ${prefix}.dmPolicy is "allowlist" but allowFrom is empty — all DMs will be blocked. Add sender IDs to ${prefix}.allowFrom, or run "${formatCliCommand("openclaw doctor --fix")}" to auto-migrate from pairing store when entries exist.`, ); }; @@ -1634,6 +1789,14 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { cfg = allowFromRepair.config; } + const allowlistRepair = await maybeRepairAllowlistPolicyAllowFrom(candidate); + if (allowlistRepair.changes.length > 0) { + note(allowlistRepair.changes.join("\n"), "Doctor changes"); + candidate = allowlistRepair.config; + pendingChanges = true; + cfg = allowlistRepair.config; + } + const emptyAllowlistWarnings = detectEmptyAllowlistPolicy(candidate); if (emptyAllowlistWarnings.length > 0) { note(emptyAllowlistWarnings.join("\n"), "Doctor warnings"); diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 0c26727266e..5c69682123e 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -295,6 +295,27 @@ export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({ if (account.enabled === false) { continue; } + const effectiveDmPolicy = account.dmPolicy ?? value.dmPolicy; + const effectiveAllowFrom = Array.isArray(account.allowFrom) + ? account.allowFrom + : value.allowFrom; + requireOpenAllowFrom({ + policy: effectiveDmPolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.telegram.accounts.*.dmPolicy="open" requires channels.telegram.allowFrom or channels.telegram.accounts.*.allowFrom to include "*"', + }); + requireAllowlistAllowFrom({ + policy: effectiveDmPolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.telegram.accounts.*.dmPolicy="allowlist" requires channels.telegram.allowFrom or channels.telegram.accounts.*.allowFrom to contain at least one sender ID', + }); + const accountWebhookUrl = typeof account.webhookUrl === "string" ? account.webhookUrl.trim() : ""; if (!accountWebhookUrl) {