fix: align channel config schemas and env precedence
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
55
extensions/matrix/src/config-schema.ts
Normal file
55
extensions/matrix/src/config-schema.ts
Normal 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,
|
||||||
|
});
|
||||||
@@ -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 }),
|
||||||
|
|||||||
22
extensions/zalo/src/config-schema.ts
Normal file
22
extensions/zalo/src/config-schema.ts
Normal 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(),
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user