fix: align channel config schemas and env precedence

This commit is contained in:
Peter Steinberger
2026-01-17 00:12:41 +00:00
parent 3ec221c70e
commit 7ecf733342
16 changed files with 249 additions and 38 deletions

View File

@@ -53,6 +53,7 @@
### Breaking ### Breaking
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702) - **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:** 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 ### Changes
- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow. - UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow.

View File

@@ -10,9 +10,38 @@ struct ConfigSchemaForm: View {
} }
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView { 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 label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description 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 { switch schema.schemaType {
case "object": case "object":
@@ -48,7 +77,7 @@ struct ConfigSchemaForm: View {
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help)) return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
case "boolean": case "boolean":
return AnyView( 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") } if let label { Text(label) } else { Text("Enabled") }
} }
.help(help ?? "") .help(help ?? "")
@@ -78,7 +107,8 @@ struct ConfigSchemaForm: View {
{ {
let hint = hintForPath(path, hints: store.configUiHints) let hint = hintForPath(path, hints: store.configUiHints)
let placeholder = hint?.placeholder ?? "" 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) { VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) } if let label { Text(label).font(.callout.weight(.semibold)) }
if let help { if let help {
@@ -87,7 +117,7 @@ struct ConfigSchemaForm: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if let options = schema.enumValues { 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) Text("Select…").tag(-1)
ForEach(options.indices, id: \ .self) { index in ForEach(options.indices, id: \ .self) { index in
Text(String(describing: options[index])).tag(index) Text(String(describing: options[index])).tag(index)
@@ -95,10 +125,10 @@ struct ConfigSchemaForm: View {
} }
.pickerStyle(.menu) .pickerStyle(.menu)
} else if sensitive { } else if sensitive {
SecureField(placeholder, text: self.stringBinding(path)) SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} else { } else {
TextField(placeholder, text: self.stringBinding(path)) TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
} }
@@ -111,6 +141,8 @@ struct ConfigSchemaForm: View {
label: String?, label: String?,
help: String?) -> some View help: String?) -> some View
{ {
let defaultValue = (schema.explicitDefault as? Double)
?? (schema.explicitDefault as? Int).map(Double.init)
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) } if let label { Text(label).font(.callout.weight(.semibold)) }
if let help { if let help {
@@ -118,7 +150,14 @@ struct ConfigSchemaForm: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
TextField("", text: self.numberBinding(path, isInteger: schema.schemaType == "integer")) TextField(
"",
text: self.numberBinding(
path,
isInteger: schema.schemaType == "integer",
defaultValue: defaultValue
)
)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
} }
@@ -223,10 +262,11 @@ struct ConfigSchemaForm: View {
} }
} }
private func stringBinding(_ path: ConfigPath) -> Binding<String> { private func stringBinding(_ path: ConfigPath, defaultValue: String?) -> Binding<String> {
Binding( Binding(
get: { get: {
store.configValue(at: path) as? String ?? "" if let value = store.configValue(at: path) as? String { return value }
return defaultValue ?? ""
}, },
set: { newValue in set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -235,10 +275,11 @@ struct ConfigSchemaForm: View {
) )
} }
private func boolBinding(_ path: ConfigPath) -> Binding<Bool> { private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
Binding( Binding(
get: { 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 set: { newValue in
store.updateConfigValue(path: path, value: newValue) store.updateConfigValue(path: path, value: newValue)
@@ -246,11 +287,16 @@ struct ConfigSchemaForm: View {
) )
} }
private func numberBinding(_ path: ConfigPath, isInteger: Bool) -> Binding<String> { private func numberBinding(
_ path: ConfigPath,
isInteger: Bool,
defaultValue: Double?
) -> Binding<String> {
Binding( Binding(
get: { get: {
guard let value = store.configValue(at: path) else { return "" } if let value = store.configValue(at: path) { return String(describing: value) }
return String(describing: value) guard let defaultValue else { return "" }
return isInteger ? String(Int(defaultValue)) : String(defaultValue)
}, },
set: { newValue in set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -263,10 +309,15 @@ struct ConfigSchemaForm: View {
) )
} }
private func enumBinding(_ path: ConfigPath, options: [Any]) -> Binding<Int> { private func enumBinding(
_ path: ConfigPath,
options: [Any],
defaultValue: Any?
) -> Binding<Int> {
Binding( Binding(
get: { 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 return options.firstIndex { option in
String(describing: option) == String(describing: value) String(describing: option) == String(describing: value)
} ?? -1 } ?? -1

View File

@@ -42,6 +42,8 @@ struct ConfigSchemaNode {
var title: String? { self.raw["title"] as? String } var title: String? { self.raw["title"] as? String }
var description: String? { self.raw["description"] as? String } var description: String? { self.raw["description"] as? String }
var enumValues: [Any]? { self.raw["enum"] as? [Any] } var enumValues: [Any]? { self.raw["enum"] as? [Any] }
var constValue: Any? { self.raw["const"] }
var explicitDefault: Any? { self.raw["default"] }
var requiredKeys: Set<String> { var requiredKeys: Set<String> {
Set((self.raw["required"] as? [String]) ?? []) Set((self.raw["required"] as? [String]) ?? [])
} }
@@ -58,11 +60,32 @@ struct ConfigSchemaNode {
return self.typeList.first return self.typeList.first
} }
var isNullSchema: Bool {
let types = self.typeList
return types.count == 1 && types.first == "null"
}
var properties: [String: ConfigSchemaNode] { var properties: [String: ConfigSchemaNode] {
guard let props = self.raw["properties"] as? [String: Any] else { return [:] } guard let props = self.raw["properties"] as? [String: Any] else { return [:] }
return props.compactMapValues { ConfigSchemaNode(raw: $0) } 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? { var items: ConfigSchemaNode? {
if let items = self.raw["items"] as? [Any], let first = items.first { if let items = self.raw["items"] as? [Any], let first = items.first {
return ConfigSchemaNode(raw: first) return ConfigSchemaNode(raw: first)
@@ -161,6 +184,15 @@ func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiH
return nil 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 { func pathKey(_ path: ConfigPath) -> String {
path.compactMap { segment -> String? in path.compactMap { segment -> String? in
switch segment { switch segment {

View File

@@ -13,7 +13,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
2) Set the token for Clawdbot: 2) Set the token for Clawdbot:
- Env: `DISCORD_BOT_TOKEN=...` - Env: `DISCORD_BOT_TOKEN=...`
- Or config: `channels.discord.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. 3) Invite the bot to your server with message permissions.
4) Start the gateway. 4) Start the gateway.
5) DM access is pairing by default; approve the pairing code on first contact. 5) DM access is pairing by default; approve the pairing code on first contact.
@@ -39,9 +39,9 @@ Minimal config:
## How it works ## How it works
1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token. 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. 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`. 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:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. Bare numeric IDs are ambiguous and rejected. 5. Direct chats: use `user:<id>` (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:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel. 6. Guild channels: use `channel:<channelId>` 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 <code>`. 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 <code>`.

View File

@@ -32,7 +32,7 @@ Details: [Plugins](/plugin)
2) Configure credentials: 2) Configure credentials:
- Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`) - Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
- Or config: `channels.matrix.*` - 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). 3) Restart the gateway (or finish onboarding).
4) DM access defaults to pairing; approve the pairing code on first contact. 4) DM access defaults to pairing; approve the pairing code on first contact.

View File

@@ -13,7 +13,7 @@ Status: production-ready for bot DMs + groups via grammY. Long-polling by defaul
2) Set the token: 2) Set the token:
- Env: `TELEGRAM_BOT_TOKEN=...` - Env: `TELEGRAM_BOT_TOKEN=...`
- Or config: `channels.telegram.botToken: "..."`. - 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. 3) Start the gateway.
4) DM access is pairing by default; approve the pairing code on first contact. 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. 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.

View File

@@ -948,7 +948,7 @@ Set `web.enabled: false` to keep it off by default.
### `channels.telegram` (bot transport) ### `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. 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. 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`). 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:<id>` (DM) or `channel:<id>` (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:<id>` (DM) or `channel:<id>` (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. 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). 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: Reaction notification modes:

View File

@@ -3,12 +3,14 @@ import {
deleteAccountFromConfigSection, deleteAccountFromConfigSection,
setAccountEnabledInConfigSection, setAccountEnabledInConfigSection,
} from "../../../src/channels/plugins/config-helpers.js"; } 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 { formatPairingApproveHint } from "../../../src/channels/plugins/helpers.js";
import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js"; import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js";
import { applyAccountNameToChannelSection } from "../../../src/channels/plugins/setup-helpers.js"; import { applyAccountNameToChannelSection } from "../../../src/channels/plugins/setup-helpers.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
import { matrixMessageActions } from "./actions.js"; import { matrixMessageActions } from "./actions.js";
import { MatrixConfigSchema } from "./config-schema.js";
import { resolveMatrixGroupRequireMention } from "./group-mentions.js"; import { resolveMatrixGroupRequireMention } from "./group-mentions.js";
import type { CoreConfig } from "./types.js"; import type { CoreConfig } from "./types.js";
import { import {
@@ -95,6 +97,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
media: true, media: true,
}, },
reload: { configPrefixes: ["channels.matrix"] }, reload: { configPrefixes: ["channels.matrix"] },
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
config: { config: {
listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig), listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) => resolveAccount: (cfg, accountId) =>

View File

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

View File

@@ -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 { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount, type ResolvedZaloAccount } from "./accounts.js";
import { zaloMessageActions } from "./actions.js"; import { zaloMessageActions } from "./actions.js";
import { ZaloConfigSchema } from "./config-schema.js";
import { import {
deleteAccountFromConfigSection, deleteAccountFromConfigSection,
setAccountEnabledInConfigSection, setAccountEnabledInConfigSection,
@@ -81,6 +83,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
blockStreaming: true, blockStreaming: true,
}, },
reload: { configPrefixes: ["channels.zalo"] }, reload: { configPrefixes: ["channels.zalo"] },
configSchema: buildChannelConfigSchema(ZaloConfigSchema),
config: { config: {
listAccountIds: (cfg) => listZaloAccountIds(cfg as CoreConfig), listAccountIds: (cfg) => listZaloAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }), resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }),

View File

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

View File

@@ -37,7 +37,7 @@ function resolveSendContext(options: ZaloSendOptions): {
const token = options.token ?? resolveZaloToken(undefined, options.accountId).token; const token = options.token ?? resolveZaloToken(undefined, options.accountId).token;
const proxy = options.proxy; 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( export async function sendMessageZalo(

View File

@@ -1,6 +1,6 @@
import type { ZodTypeAny } from "zod"; import type { ZodTypeAny } from "zod";
import type { ChannelConfigSchema } from "./types.js"; import type { ChannelConfigSchema } from "./types.plugin.js";
export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema { export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema {
return { return {

View File

@@ -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; const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "tok-env"; process.env.TELEGRAM_BOT_TOKEN = "tok-env";
try { 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", () => { it("does not fall back when accountId is explicitly provided", () => {
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = ""; process.env.TELEGRAM_BOT_TOKEN = "";

View File

@@ -74,8 +74,8 @@ export function resolveTelegramAccount(params: {
if (primary.tokenSource !== "none") return primary; if (primary.tokenSource !== "none") return primary;
// If accountId is omitted, prefer a configured account token over failing on // If accountId is omitted, prefer a configured account token over failing on
// the implicit "default" account. This keeps env-based setups working (env // the implicit "default" account. This keeps env-based setups working while
// still wins) while making config-only tokens work for things like heartbeats. // making config-only tokens work for things like heartbeats.
const fallbackId = resolveDefaultTelegramAccountId(params.cfg); const fallbackId = resolveDefaultTelegramAccountId(params.cfg);
if (fallbackId === primary.accountId) return primary; if (fallbackId === primary.accountId) return primary;
const fallback = resolve(fallbackId); const fallback = resolve(fallbackId);

View File

@@ -73,8 +73,10 @@ export function renderNode(params: {
const allLiterals = literals.every((v) => v !== undefined); const allLiterals = literals.every((v) => v !== undefined);
if (allLiterals && literals.length > 0) { if (allLiterals && literals.length > 0) {
const resolvedValue = value ?? schema.default;
const currentIndex = literals.findIndex( const currentIndex = literals.findIndex(
(lit) => lit === value || String(lit) === String(value), (lit) =>
lit === resolvedValue || String(lit) === String(resolvedValue),
); );
return html` return html`
<label class="field"> <label class="field">
@@ -101,8 +103,10 @@ export function renderNode(params: {
if (schema.enum) { if (schema.enum) {
const options = schema.enum; const options = schema.enum;
const resolvedValue = value ?? schema.default;
const currentIndex = options.findIndex( const currentIndex = options.findIndex(
(opt) => opt === value || String(opt) === String(value), (opt) =>
opt === resolvedValue || String(opt) === String(resolvedValue),
); );
const unset = "__unset__"; const unset = "__unset__";
return html` return html`
@@ -128,7 +132,11 @@ export function renderNode(params: {
} }
if (type === "object") { if (type === "object") {
const obj = (value ?? {}) as Record<string, unknown>; const fallback = value ?? schema.default;
const obj =
fallback && typeof fallback === "object" && !Array.isArray(fallback)
? (fallback as Record<string, unknown>)
: {};
const props = schema.properties ?? {}; const props = schema.properties ?? {};
const entries = Object.entries(props); const entries = Object.entries(props);
const sorted = entries.sort((a, b) => { const sorted = entries.sort((a, b) => {
@@ -184,7 +192,11 @@ export function renderNode(params: {
<div class="muted">Unsupported array schema. Use Raw.</div> <div class="muted">Unsupported array schema. Use Raw.</div>
</div>`; </div>`;
} }
const arr = Array.isArray(value) ? value : []; const arr = Array.isArray(value)
? value
: Array.isArray(schema.default)
? schema.default
: [];
return html` return html`
<div class="field" style="margin-top: 12px;"> <div class="field" style="margin-top: 12px;">
${showLabel ? html`<span>${label}</span>` : nothing} ${showLabel ? html`<span>${label}</span>` : nothing}
@@ -235,13 +247,19 @@ export function renderNode(params: {
} }
if (type === "boolean") { if (type === "boolean") {
const displayValue =
typeof value === "boolean"
? value
: typeof schema.default === "boolean"
? schema.default
: false;
return html` return html`
<label class="field"> <label class="field">
${showLabel ? html`<span>${label}</span>` : nothing} ${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing} ${help ? html`<div class="muted">${help}</div>` : nothing}
<input <input
type="checkbox" type="checkbox"
.checked=${Boolean(value)} .checked=${displayValue}
?disabled=${disabled} ?disabled=${disabled}
@change=${(e: Event) => @change=${(e: Event) =>
onPatch(path, (e.target as HTMLInputElement).checked)} onPatch(path, (e.target as HTMLInputElement).checked)}
@@ -251,13 +269,14 @@ export function renderNode(params: {
} }
if (type === "number" || type === "integer") { if (type === "number" || type === "integer") {
const displayValue = value ?? schema.default;
return html` return html`
<label class="field"> <label class="field">
${showLabel ? html`<span>${label}</span>` : nothing} ${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing} ${help ? html`<div class="muted">${help}</div>` : nothing}
<input <input
type="number" type="number"
.value=${value == null ? "" : String(value)} .value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled} ?disabled=${disabled}
@input=${(e: Event) => { @input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value; const raw = (e.target as HTMLInputElement).value;
@@ -272,6 +291,7 @@ export function renderNode(params: {
if (type === "string") { if (type === "string") {
const isSensitive = hint?.sensitive ?? isSensitivePath(path); const isSensitive = hint?.sensitive ?? isSensitivePath(path);
const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : ""); const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : "");
const displayValue = value ?? schema.default ?? "";
return html` return html`
<label class="field"> <label class="field">
${showLabel ? html`<span>${label}</span>` : nothing} ${showLabel ? html`<span>${label}</span>` : nothing}
@@ -279,7 +299,7 @@ export function renderNode(params: {
<input <input
type=${isSensitive ? "password" : "text"} type=${isSensitive ? "password" : "text"}
placeholder=${placeholder} placeholder=${placeholder}
.value=${value == null ? "" : String(value)} .value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled} ?disabled=${disabled}
@input=${(e: Event) => @input=${(e: Event) =>
onPatch(path, (e.target as HTMLInputElement).value)} onPatch(path, (e.target as HTMLInputElement).value)}