diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cb60f3cc..d55f99e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ ### Breaking - **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702) - **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`. +- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only when config is unset). ### Changes - UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow. diff --git a/apps/macos/Sources/Clawdbot/ChannelConfigForm.swift b/apps/macos/Sources/Clawdbot/ChannelConfigForm.swift index dc7dcbf00..56fdae1be 100644 --- a/apps/macos/Sources/Clawdbot/ChannelConfigForm.swift +++ b/apps/macos/Sources/Clawdbot/ChannelConfigForm.swift @@ -10,9 +10,38 @@ struct ConfigSchemaForm: View { } private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView { - let value = store.configValue(at: path) + let storedValue = store.configValue(at: path) + let value = storedValue ?? schema.explicitDefault let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description + let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf + + if !variants.isEmpty { + let nonNull = variants.filter { !$0.isNullSchema } + if nonNull.count == 1, let only = nonNull.first { + return self.renderNode(only, path: path) + } + let literals = nonNull.compactMap { $0.literalValue } + if !literals.isEmpty, literals.count == nonNull.count { + return AnyView( + VStack(alignment: .leading, spacing: 6) { + if let label { Text(label).font(.callout.weight(.semibold)) } + if let help { + Text(help) + .font(.caption) + .foregroundStyle(.secondary) + } + Picker("", selection: self.enumBinding(path, options: literals, defaultValue: schema.explicitDefault)) { + Text("Select…").tag(-1) + ForEach(literals.indices, id: \ .self) { index in + Text(String(describing: literals[index])).tag(index) + } + } + .pickerStyle(.menu) + } + ) + } + } switch schema.schemaType { case "object": @@ -48,7 +77,7 @@ struct ConfigSchemaForm: View { return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help)) case "boolean": return AnyView( - Toggle(isOn: self.boolBinding(path)) { + Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) { if let label { Text(label) } else { Text("Enabled") } } .help(help ?? "") @@ -78,7 +107,8 @@ struct ConfigSchemaForm: View { { let hint = hintForPath(path, hints: store.configUiHints) let placeholder = hint?.placeholder ?? "" - let sensitive = hint?.sensitive ?? false + let sensitive = hint?.sensitive ?? isSensitivePath(path) + let defaultValue = schema.explicitDefault as? String VStack(alignment: .leading, spacing: 6) { if let label { Text(label).font(.callout.weight(.semibold)) } if let help { @@ -87,7 +117,7 @@ struct ConfigSchemaForm: View { .foregroundStyle(.secondary) } if let options = schema.enumValues { - Picker("", selection: self.enumBinding(path, options: options)) { + Picker("", selection: self.enumBinding(path, options: options, defaultValue: schema.explicitDefault)) { Text("Select…").tag(-1) ForEach(options.indices, id: \ .self) { index in Text(String(describing: options[index])).tag(index) @@ -95,10 +125,10 @@ struct ConfigSchemaForm: View { } .pickerStyle(.menu) } else if sensitive { - SecureField(placeholder, text: self.stringBinding(path)) + SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue)) .textFieldStyle(.roundedBorder) } else { - TextField(placeholder, text: self.stringBinding(path)) + TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue)) .textFieldStyle(.roundedBorder) } } @@ -111,6 +141,8 @@ struct ConfigSchemaForm: View { label: String?, help: String?) -> some View { + let defaultValue = (schema.explicitDefault as? Double) + ?? (schema.explicitDefault as? Int).map(Double.init) VStack(alignment: .leading, spacing: 6) { if let label { Text(label).font(.callout.weight(.semibold)) } if let help { @@ -118,7 +150,14 @@ struct ConfigSchemaForm: View { .font(.caption) .foregroundStyle(.secondary) } - TextField("", text: self.numberBinding(path, isInteger: schema.schemaType == "integer")) + TextField( + "", + text: self.numberBinding( + path, + isInteger: schema.schemaType == "integer", + defaultValue: defaultValue + ) + ) .textFieldStyle(.roundedBorder) } } @@ -223,10 +262,11 @@ struct ConfigSchemaForm: View { } } - private func stringBinding(_ path: ConfigPath) -> Binding { + private func stringBinding(_ path: ConfigPath, defaultValue: String?) -> Binding { Binding( get: { - store.configValue(at: path) as? String ?? "" + if let value = store.configValue(at: path) as? String { return value } + return defaultValue ?? "" }, set: { newValue in let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) @@ -235,10 +275,11 @@ struct ConfigSchemaForm: View { ) } - private func boolBinding(_ path: ConfigPath) -> Binding { + private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding { Binding( get: { - store.configValue(at: path) as? Bool ?? false + if let value = store.configValue(at: path) as? Bool { return value } + return defaultValue ?? false }, set: { newValue in store.updateConfigValue(path: path, value: newValue) @@ -246,11 +287,16 @@ struct ConfigSchemaForm: View { ) } - private func numberBinding(_ path: ConfigPath, isInteger: Bool) -> Binding { + private func numberBinding( + _ path: ConfigPath, + isInteger: Bool, + defaultValue: Double? + ) -> Binding { Binding( get: { - guard let value = store.configValue(at: path) else { return "" } - return String(describing: value) + if let value = store.configValue(at: path) { return String(describing: value) } + guard let defaultValue else { return "" } + return isInteger ? String(Int(defaultValue)) : String(defaultValue) }, set: { newValue in let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) @@ -263,10 +309,15 @@ struct ConfigSchemaForm: View { ) } - private func enumBinding(_ path: ConfigPath, options: [Any]) -> Binding { + private func enumBinding( + _ path: ConfigPath, + options: [Any], + defaultValue: Any? + ) -> Binding { Binding( get: { - guard let value = store.configValue(at: path) else { return -1 } + let value = store.configValue(at: path) ?? defaultValue + guard let value else { return -1 } return options.firstIndex { option in String(describing: option) == String(describing: value) } ?? -1 diff --git a/apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift b/apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift index 0b2c8105d..0f1d09545 100644 --- a/apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift +++ b/apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift @@ -42,6 +42,8 @@ struct ConfigSchemaNode { var title: String? { self.raw["title"] as? String } var description: String? { self.raw["description"] as? String } var enumValues: [Any]? { self.raw["enum"] as? [Any] } + var constValue: Any? { self.raw["const"] } + var explicitDefault: Any? { self.raw["default"] } var requiredKeys: Set { Set((self.raw["required"] as? [String]) ?? []) } @@ -58,11 +60,32 @@ struct ConfigSchemaNode { return self.typeList.first } + var isNullSchema: Bool { + let types = self.typeList + return types.count == 1 && types.first == "null" + } + var properties: [String: ConfigSchemaNode] { guard let props = self.raw["properties"] as? [String: Any] else { return [:] } return props.compactMapValues { ConfigSchemaNode(raw: $0) } } + var anyOf: [ConfigSchemaNode] { + guard let raw = self.raw["anyOf"] as? [Any] else { return [] } + return raw.compactMap { ConfigSchemaNode(raw: $0) } + } + + var oneOf: [ConfigSchemaNode] { + guard let raw = self.raw["oneOf"] as? [Any] else { return [] } + return raw.compactMap { ConfigSchemaNode(raw: $0) } + } + + var literalValue: Any? { + if let constValue { return constValue } + if let enumValues, enumValues.count == 1 { return enumValues[0] } + return nil + } + var items: ConfigSchemaNode? { if let items = self.raw["items"] as? [Any], let first = items.first { return ConfigSchemaNode(raw: first) @@ -161,6 +184,15 @@ func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiH return nil } +func isSensitivePath(_ path: ConfigPath) -> Bool { + let key = pathKey(path).lowercased() + return key.contains("token") + || key.contains("password") + || key.contains("secret") + || key.contains("apikey") + || key.hasSuffix("key") +} + func pathKey(_ path: ConfigPath) -> String { path.compactMap { segment -> String? in switch segment { diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 90052d403..2edd23966 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -13,7 +13,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa 2) Set the token for Clawdbot: - Env: `DISCORD_BOT_TOKEN=...` - Or config: `channels.discord.token: "..."`. - - If both are set, config wins; env is fallback. + - If both are set, config takes precedence (env fallback is default-account only). 3) Invite the bot to your server with message permissions. 4) Start the gateway. 5) DM access is pairing by default; approve the pairing code on first contact. @@ -39,9 +39,9 @@ Minimal config: ## How it works 1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token. 2. Invite the bot to your server with the permissions required to read/send messages where you want to use it. -3. Configure Clawdbot with `DISCORD_BOT_TOKEN` (or `channels.discord.token` in `~/.clawdbot/clawdbot.json`). +3. Configure Clawdbot with `channels.discord.token` (or `DISCORD_BOT_TOKEN` as a fallback). 4. Run the gateway; it auto-starts the Discord channel when a token is available (config first, env fallback) and `channels.discord.enabled` is not `false`. - - If you prefer env vars, set `DISCORD_BOT_TOKEN` (and omit config). + - If you prefer env vars, set `DISCORD_BOT_TOKEN` (a config block is optional). 5. Direct chats: use `user:` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. Bare numeric IDs are ambiguous and rejected. 6. Guild channels: use `channel:` for delivery. Mentions are required by default and can be set per guild or per channel. 7. Direct chats: secure by default via `channels.discord.dm.policy` (default: `"pairing"`). Unknown senders get a pairing code (expires after 1 hour); approve via `clawdbot pairing approve discord `. diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 22ffbaf2f..b2349ca50 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -32,7 +32,7 @@ Details: [Plugins](/plugin) 2) Configure credentials: - Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`) - Or config: `channels.matrix.*` - - Config takes precedence over env; env is fallback. + - If both are set, config takes precedence. 3) Restart the gateway (or finish onboarding). 4) DM access defaults to pairing; approve the pairing code on first contact. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index a7064d1e7..77097cb98 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -13,7 +13,7 @@ Status: production-ready for bot DMs + groups via grammY. Long-polling by defaul 2) Set the token: - Env: `TELEGRAM_BOT_TOKEN=...` - Or config: `channels.telegram.botToken: "..."`. - - If both are set, config wins; env is fallback. + - If both are set, config takes precedence (env fallback is default-account only). 3) Start the gateway. 4) DM access is pairing by default; approve the pairing code on first contact. @@ -61,7 +61,8 @@ Example: } ``` -Env option: `TELEGRAM_BOT_TOKEN=...` (works for the default account; used only when config is missing). +Env option: `TELEGRAM_BOT_TOKEN=...` (works for the default account). +If both env and config are set, config takes precedence. Multi-account support: use `channels.telegram.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index ddf299e5c..5b85428b2 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -948,7 +948,7 @@ Set `web.enabled: false` to keep it off by default. ### `channels.telegram` (bot transport) -Clawdbot starts Telegram only when a `channels.telegram` config section exists. The bot token is resolved from `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken`. +Clawdbot starts Telegram only when a `channels.telegram` config section exists. The bot token is resolved from `channels.telegram.botToken` (or `channels.telegram.tokenFile`), with `TELEGRAM_BOT_TOKEN` as a fallback for the default account. Set `channels.telegram.enabled: false` to disable automatic startup. Multi-account support lives under `channels.telegram.accounts` (see the multi-account section above). Env tokens only apply to the default account. Set `channels.telegram.configWrites: false` to block Telegram-initiated config writes (including supergroup ID migrations and `/config set|unset`). @@ -1081,7 +1081,7 @@ Multi-account support lives under `channels.discord.accounts` (see the multi-acc } ``` -Clawdbot starts Discord only when a `channels.discord` config section exists. The token is resolved from `DISCORD_BOT_TOKEN` or `channels.discord.token` (unless `channels.discord.enabled` is `false`). Use `user:` (DM) or `channel:` (guild channel) when specifying delivery targets for cron/CLI commands; bare numeric IDs are ambiguous and rejected. +Clawdbot starts Discord only when a `channels.discord` config section exists. The token is resolved from `channels.discord.token`, with `DISCORD_BOT_TOKEN` as a fallback for the default account (unless `channels.discord.enabled` is `false`). Use `user:` (DM) or `channel:` (guild channel) when specifying delivery targets for cron/CLI commands; bare numeric IDs are ambiguous and rejected. Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity. Bot-authored messages are ignored by default. Enable with `channels.discord.allowBots` (own messages are still filtered to prevent self-reply loops). Reaction notification modes: diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 067820b3a..e8baf8aa9 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -3,12 +3,14 @@ import { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "../../../src/channels/plugins/config-helpers.js"; +import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js"; import { formatPairingApproveHint } from "../../../src/channels/plugins/helpers.js"; import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js"; import { applyAccountNameToChannelSection } from "../../../src/channels/plugins/setup-helpers.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import { matrixMessageActions } from "./actions.js"; +import { MatrixConfigSchema } from "./config-schema.js"; import { resolveMatrixGroupRequireMention } from "./group-mentions.js"; import type { CoreConfig } from "./types.js"; import { @@ -95,6 +97,7 @@ export const matrixPlugin: ChannelPlugin = { media: true, }, reload: { configPrefixes: ["channels.matrix"] }, + configSchema: buildChannelConfigSchema(MatrixConfigSchema), config: { listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig), resolveAccount: (cfg, accountId) => diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts new file mode 100644 index 000000000..04966621d --- /dev/null +++ b/extensions/matrix/src/config-schema.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +const allowFromEntry = z.union([z.string(), z.number()]); + +const matrixActionSchema = z + .object({ + reactions: z.boolean().optional(), + messages: z.boolean().optional(), + pins: z.boolean().optional(), + memberInfo: z.boolean().optional(), + channelInfo: z.boolean().optional(), + }) + .optional(); + +const matrixDmSchema = z + .object({ + enabled: z.boolean().optional(), + policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), + allowFrom: z.array(allowFromEntry).optional(), + }) + .optional(); + +const matrixRoomSchema = z + .object({ + enabled: z.boolean().optional(), + allow: z.boolean().optional(), + requireMention: z.boolean().optional(), + autoReply: z.boolean().optional(), + users: z.array(allowFromEntry).optional(), + skills: z.array(z.string()).optional(), + systemPrompt: z.string().optional(), + }) + .optional(); + +export const MatrixConfigSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + homeserver: z.string().optional(), + userId: z.string().optional(), + accessToken: z.string().optional(), + password: z.string().optional(), + deviceName: z.string().optional(), + initialSyncLimit: z.number().optional(), + allowlistOnly: z.boolean().optional(), + groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(), + replyToMode: z.enum(["off", "first", "all"]).optional(), + threadReplies: z.enum(["off", "inbound", "always"]).optional(), + textChunkLimit: z.number().optional(), + mediaMaxMb: z.number().optional(), + autoJoin: z.enum(["always", "allowlist", "off"]).optional(), + autoJoinAllowlist: z.array(allowFromEntry).optional(), + dm: matrixDmSchema, + rooms: z.object({}).catchall(matrixRoomSchema).optional(), + actions: matrixActionSchema, +}); diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 1d24cbfa5..fdb3a7ef4 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -1,8 +1,10 @@ -import type { ChannelDock, ChannelPlugin } from "../../src/channels/plugins/types.js"; -import type { ChannelAccountSnapshot } from "../../src/channels/plugins/types.js"; +import type { ChannelAccountSnapshot } from "../../../src/channels/plugins/types.js"; +import type { ChannelDock, ChannelPlugin } from "../../../src/channels/plugins/types.js"; +import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js"; import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount, type ResolvedZaloAccount } from "./accounts.js"; import { zaloMessageActions } from "./actions.js"; +import { ZaloConfigSchema } from "./config-schema.js"; import { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, @@ -81,6 +83,7 @@ export const zaloPlugin: ChannelPlugin = { blockStreaming: true, }, reload: { configPrefixes: ["channels.zalo"] }, + configSchema: buildChannelConfigSchema(ZaloConfigSchema), config: { listAccountIds: (cfg) => listZaloAccountIds(cfg as CoreConfig), resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }), diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts new file mode 100644 index 000000000..3ab955848 --- /dev/null +++ b/extensions/zalo/src/config-schema.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +const allowFromEntry = z.union([z.string(), z.number()]); + +const zaloAccountSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + botToken: z.string().optional(), + tokenFile: z.string().optional(), + webhookUrl: z.string().optional(), + webhookSecret: z.string().optional(), + webhookPath: z.string().optional(), + dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), + allowFrom: z.array(allowFromEntry).optional(), + mediaMaxMb: z.number().optional(), + proxy: z.string().optional(), +}); + +export const ZaloConfigSchema = zaloAccountSchema.extend({ + accounts: z.object({}).catchall(zaloAccountSchema).optional(), + defaultAccount: z.string().optional(), +}); diff --git a/extensions/zalo/src/send.ts b/extensions/zalo/src/send.ts index 202520cc8..18fba3301 100644 --- a/extensions/zalo/src/send.ts +++ b/extensions/zalo/src/send.ts @@ -37,7 +37,7 @@ function resolveSendContext(options: ZaloSendOptions): { const token = options.token ?? resolveZaloToken(undefined, options.accountId).token; const proxy = options.proxy; - return { token: token || process.env.ZALO_BOT_TOKEN?.trim() || "", fetcher: resolveZaloProxyFetch(proxy) }; + return { token, fetcher: resolveZaloProxyFetch(proxy) }; } export async function sendMessageZalo( diff --git a/src/channels/plugins/config-schema.ts b/src/channels/plugins/config-schema.ts index ff299995a..f5b4f8b80 100644 --- a/src/channels/plugins/config-schema.ts +++ b/src/channels/plugins/config-schema.ts @@ -1,6 +1,6 @@ import type { ZodTypeAny } from "zod"; -import type { ChannelConfigSchema } from "./types.js"; +import type { ChannelConfigSchema } from "./types.plugin.js"; export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema { return { diff --git a/src/telegram/accounts.test.ts b/src/telegram/accounts.test.ts index d17255cc4..d25d45276 100644 --- a/src/telegram/accounts.test.ts +++ b/src/telegram/accounts.test.ts @@ -27,7 +27,7 @@ describe("resolveTelegramAccount", () => { } }); - it("prefers TELEGRAM_BOT_TOKEN when accountId is omitted", () => { + it("uses TELEGRAM_BOT_TOKEN when default account config is missing", () => { const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; process.env.TELEGRAM_BOT_TOKEN = "tok-env"; try { @@ -50,6 +50,29 @@ describe("resolveTelegramAccount", () => { } }); + it("prefers default config token over TELEGRAM_BOT_TOKEN", () => { + const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; + process.env.TELEGRAM_BOT_TOKEN = "tok-env"; + try { + const cfg: ClawdbotConfig = { + channels: { + telegram: { botToken: "tok-config" }, + }, + }; + + const account = resolveTelegramAccount({ cfg }); + expect(account.accountId).toBe("default"); + expect(account.token).toBe("tok-config"); + expect(account.tokenSource).toBe("config"); + } finally { + if (prevTelegramToken === undefined) { + delete process.env.TELEGRAM_BOT_TOKEN; + } else { + process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; + } + } + }); + it("does not fall back when accountId is explicitly provided", () => { const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; process.env.TELEGRAM_BOT_TOKEN = ""; diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts index d29623c6c..d13b30440 100644 --- a/src/telegram/accounts.ts +++ b/src/telegram/accounts.ts @@ -74,8 +74,8 @@ export function resolveTelegramAccount(params: { if (primary.tokenSource !== "none") return primary; // If accountId is omitted, prefer a configured account token over failing on - // the implicit "default" account. This keeps env-based setups working (env - // still wins) while making config-only tokens work for things like heartbeats. + // the implicit "default" account. This keeps env-based setups working while + // making config-only tokens work for things like heartbeats. const fallbackId = resolveDefaultTelegramAccountId(params.cfg); if (fallbackId === primary.accountId) return primary; const fallback = resolve(fallbackId); diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index ca45cf3be..7d55f2226 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -73,8 +73,10 @@ export function renderNode(params: { const allLiterals = literals.every((v) => v !== undefined); if (allLiterals && literals.length > 0) { + const resolvedValue = value ?? schema.default; const currentIndex = literals.findIndex( - (lit) => lit === value || String(lit) === String(value), + (lit) => + lit === resolvedValue || String(lit) === String(resolvedValue), ); return html`