Merge pull request #1040 from clawdbot/shadow/config-ui

Config: schema-driven channels and settings
This commit is contained in:
Peter Steinberger 2026-01-17 00:45:42 +00:00 committed by GitHub
commit c8b865d582
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
95 changed files with 2542 additions and 6365 deletions

View File

@ -9,8 +9,7 @@
- Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow. - Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow.
### Breaking ### Breaking
- **BREAKING:** Discord/Telegram channel tokens now prefer config over env (env is fallback only). - **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
- **BREAKING:** Matrix channel credentials now prefer config over env (env is fallback only).
### Changes ### Changes
- Tools: improve `web_fetch` extraction using Readability (with fallback). - Tools: improve `web_fetch` extraction using Readability (with fallback).
@ -53,8 +52,10 @@
### 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). (#1040) — thanks @thewilloftheshadow.
### Changes ### Changes
- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow.
- CLI: set process titles to `clawdbot-<command>` for clearer process listings. - CLI: set process titles to `clawdbot-<command>` for clearer process listings.
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware). - CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups. - Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups.
@ -68,6 +69,7 @@
- TUI: show provider/model labels for the active session and default model. - TUI: show provider/model labels for the active session and default model.
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example. - Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections. - UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
- UI: add session deletion action in Control UI sessions list. (#1017) — thanks @Szpadel.
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`. - Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs). - Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts. - Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
@ -92,6 +94,11 @@
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE. - Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
### Fixes ### Fixes
- Messages: make `/stop` clear queued followups and pending session lane work for a hard abort.
- Messages: make `/stop` abort active sub-agent runs spawned from the requester session and report how many were stopped.
- WhatsApp: report linked status consistently in channel status. (#1050) — thanks @YuriNachos.
- Sessions: keep per-session overrides when `/new` resets compaction counters. (#1050) — thanks @YuriNachos.
- Skills: allow OpenAI image-gen helper to handle URL or base64 responses. (#1050) — thanks @YuriNachos.
- WhatsApp: default response prefix only for self-chat, using identity name when set. - WhatsApp: default response prefix only for self-chat, using identity name when set.
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel. - Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
- iMessage: treat missing `imsg rpc` support as fatal to avoid restart loops. - iMessage: treat missing `imsg rpc` support as fatal to avoid restart loops.

View File

@ -0,0 +1,368 @@
import SwiftUI
struct ConfigSchemaForm: View {
@Bindable var store: ChannelsStore
let schema: ConfigSchemaNode
let path: ConfigPath
var body: some View {
self.renderNode(schema, path: path)
}
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
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":
return AnyView(
VStack(alignment: .leading, spacing: 12) {
if let label {
Text(label)
.font(.callout.weight(.semibold))
}
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
let properties = schema.properties
let sortedKeys = properties.keys.sorted { lhs, rhs in
let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0
let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
ForEach(sortedKeys, id: \ .self) { key in
if let child = properties[key] {
self.renderNode(child, path: path + [.key(key)])
}
}
if schema.allowsAdditionalProperties {
self.renderAdditionalProperties(schema, path: path, value: value)
}
}
)
case "array":
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
case "boolean":
return AnyView(
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
if let label { Text(label) } else { Text("Enabled") }
}
.help(help ?? "")
)
case "number", "integer":
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
case "string":
return AnyView(self.renderStringField(schema, path: path, label: label, help: help))
default:
return AnyView(
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
Text("Unsupported field type.")
.font(.caption)
.foregroundStyle(.secondary)
}
)
}
}
@ViewBuilder
private func renderStringField(
_ schema: ConfigSchemaNode,
path: ConfigPath,
label: String?,
help: String?) -> some View
{
let hint = hintForPath(path, hints: store.configUiHints)
let placeholder = hint?.placeholder ?? ""
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 {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
if let options = schema.enumValues {
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)
}
}
.pickerStyle(.menu)
} else if sensitive {
SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
} else {
TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
}
}
}
@ViewBuilder
private func renderNumberField(
_ schema: ConfigSchemaNode,
path: ConfigPath,
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 {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
TextField(
"",
text: self.numberBinding(
path,
isInteger: schema.schemaType == "integer",
defaultValue: defaultValue
)
)
.textFieldStyle(.roundedBorder)
}
}
@ViewBuilder
private func renderArray(
_ schema: ConfigSchemaNode,
path: ConfigPath,
value: Any?,
label: String?,
help: String?) -> some View
{
let items = value as? [Any] ?? []
let itemSchema = schema.items
VStack(alignment: .leading, spacing: 10) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(items.indices, id: \ .self) { index in
HStack(alignment: .top, spacing: 8) {
if let itemSchema {
self.renderNode(itemSchema, path: path + [.index(index)])
} else {
Text(String(describing: items[index]))
}
Button("Remove") {
var next = items
next.remove(at: index)
store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
Button("Add") {
var next = items
if let itemSchema {
next.append(itemSchema.defaultValue)
} else {
next.append("")
}
store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
@ViewBuilder
private func renderAdditionalProperties(
_ schema: ConfigSchemaNode,
path: ConfigPath,
value: Any?) -> some View
{
if let additionalSchema = schema.additionalProperties {
let dict = value as? [String: Any] ?? [:]
let reserved = Set(schema.properties.keys)
let extras = dict.keys.filter { !reserved.contains($0) }.sorted()
VStack(alignment: .leading, spacing: 8) {
Text("Extra entries")
.font(.callout.weight(.semibold))
if extras.isEmpty {
Text("No extra entries yet.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(extras, id: \ .self) { key in
let itemPath: ConfigPath = path + [.key(key)]
HStack(alignment: .top, spacing: 8) {
TextField("Key", text: self.mapKeyBinding(path: path, key: key))
.textFieldStyle(.roundedBorder)
.frame(width: 160)
self.renderNode(additionalSchema, path: itemPath)
Button("Remove") {
var next = dict
next.removeValue(forKey: key)
store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
Button("Add") {
var next = dict
var index = 1
var key = "new-\(index)"
while next[key] != nil {
index += 1
key = "new-\(index)"
}
next[key] = additionalSchema.defaultValue
store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
private func stringBinding(_ path: ConfigPath, defaultValue: String?) -> Binding<String> {
Binding(
get: {
if let value = store.configValue(at: path) as? String { return value }
return defaultValue ?? ""
},
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
}
)
}
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
Binding(
get: {
if let value = store.configValue(at: path) as? Bool { return value }
return defaultValue ?? false
},
set: { newValue in
store.updateConfigValue(path: path, value: newValue)
}
)
}
private func numberBinding(
_ path: ConfigPath,
isInteger: Bool,
defaultValue: Double?
) -> Binding<String> {
Binding(
get: {
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)
if trimmed.isEmpty {
store.updateConfigValue(path: path, value: nil)
} else if let value = Double(trimmed) {
store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
}
}
)
}
private func enumBinding(
_ path: ConfigPath,
options: [Any],
defaultValue: Any?
) -> Binding<Int> {
Binding(
get: {
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
},
set: { index in
guard index >= 0, index < options.count else {
store.updateConfigValue(path: path, value: nil)
return
}
store.updateConfigValue(path: path, value: options[index])
}
)
}
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
Binding(
get: { key },
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard trimmed != key else { return }
let current = store.configValue(at: path) as? [String: Any] ?? [:]
guard current[trimmed] == nil else { return }
var next = current
next[trimmed] = current[key]
next.removeValue(forKey: key)
store.updateConfigValue(path: path, value: next)
}
)
}
}
struct ChannelConfigForm: View {
@Bindable var store: ChannelsStore
let channelId: String
var body: some View {
if store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let schema = store.channelConfigSchema(for: channelId) {
ConfigSchemaForm(store: store, schema: schema, path: [.key("channels"), .key(channelId)])
} else {
Text("Schema unavailable for this channel.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}

View File

@ -0,0 +1,139 @@
import SwiftUI
extension ChannelsSettings {
func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
GroupBox(title) {
VStack(alignment: .leading, spacing: 10) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder
func channelHeaderActions(_ channel: ChannelItem) -> some View {
HStack(spacing: 8) {
if channel.id == "whatsapp" {
Button("Logout") {
Task { await self.store.logoutWhatsApp() }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
}
if channel.id == "telegram" {
Button("Logout") {
Task { await self.store.logoutTelegram() }
}
.buttonStyle(.bordered)
.disabled(self.store.telegramBusy)
}
Button {
Task { await self.store.refresh(probe: true) }
} label: {
if self.store.isRefreshing {
ProgressView().controlSize(.small)
} else {
Text("Refresh")
}
}
.buttonStyle(.bordered)
.disabled(self.store.isRefreshing)
}
.controlSize(.small)
}
var whatsAppSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Linking") {
if let message = self.store.whatsappLoginMessage {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
Image(nsImage: image)
.resizable()
.interpolation(.none)
.frame(width: 180, height: 180)
.cornerRadius(8)
}
HStack(spacing: 12) {
Button {
Task { await self.store.startWhatsAppLogin(force: false) }
} label: {
if self.store.whatsappBusy {
ProgressView().controlSize(.small)
} else {
Text("Show QR")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.whatsappBusy)
Button("Relink") {
Task { await self.store.startWhatsAppLogin(force: true) }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
}
.font(.caption)
}
self.configEditorSection(channelId: "whatsapp")
}
}
@ViewBuilder
func genericChannelSection(_ channel: ChannelItem) -> some View {
VStack(alignment: .leading, spacing: 16) {
self.configEditorSection(channelId: channel.id)
}
}
@ViewBuilder
private func configEditorSection(channelId: String) -> some View {
self.formSection("Configuration") {
ChannelConfigForm(store: self.store, channelId: channelId)
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveConfigDraft() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig || !self.store.configDirty)
Button("Reload") {
Task { await self.store.reloadConfigDraft() }
}
.buttonStyle(.bordered)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
@ViewBuilder
var configStatusMessage: some View {
if let status = self.store.configStatus {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}

View File

@ -1,6 +1,7 @@
import ClawdbotProtocol
import SwiftUI import SwiftUI
extension ConnectionsSettings { extension ChannelsSettings {
private func channelStatus<T: Decodable>( private func channelStatus<T: Decodable>(
_ id: String, _ id: String,
as type: T.Type) -> T? as type: T.Type) -> T?
@ -242,16 +243,18 @@ extension ConnectionsSettings {
return lines.isEmpty ? nil : lines.joined(separator: " · ") return lines.isEmpty ? nil : lines.joined(separator: " · ")
} }
var isTelegramTokenLocked: Bool { var orderedChannels: [ChannelItem] {
self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?.tokenSource == "env" let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
} let order = self.store.snapshot?.channelOrder ?? fallback
let channels = order.enumerated().map { index, id in
var isDiscordTokenLocked: Bool { ChannelItem(
self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?.tokenSource == "env" id: id,
} title: self.resolveChannelTitle(id),
detailTitle: self.resolveChannelDetailTitle(id),
var orderedChannels: [ConnectionChannel] { systemImage: self.resolveChannelSystemImage(id),
ConnectionChannel.allCases.sorted { lhs, rhs in sortOrder: index)
}
return channels.sorted { lhs, rhs in
let lhsEnabled = self.channelEnabled(lhs) let lhsEnabled = self.channelEnabled(lhs)
let rhsEnabled = self.channelEnabled(rhs) let rhsEnabled = self.channelEnabled(rhs)
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled } if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
@ -259,11 +262,11 @@ extension ConnectionsSettings {
} }
} }
var enabledChannels: [ConnectionChannel] { var enabledChannels: [ChannelItem] {
self.orderedChannels.filter { self.channelEnabled($0) } self.orderedChannels.filter { self.channelEnabled($0) }
} }
var availableChannels: [ConnectionChannel] { var availableChannels: [ChannelItem] {
self.orderedChannels.filter { !self.channelEnabled($0) } self.orderedChannels.filter { !self.channelEnabled($0) }
} }
@ -277,143 +280,183 @@ extension ConnectionsSettings {
} }
} }
func channelEnabled(_ channel: ConnectionChannel) -> Bool { func channelEnabled(_ channel: ChannelItem) -> Bool {
switch channel { let status = self.channelStatusDictionary(channel.id)
case .whatsapp: let configured = status?["configured"]?.boolValue ?? false
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) let running = status?["running"]?.boolValue ?? false
else { return false } let connected = status?["connected"]?.boolValue ?? false
return status.configured || status.linked || status.running let accountActive = self.store.snapshot?.channelAccounts[channel.id]?.contains(
case .telegram: where: { $0.configured == true || $0.running == true || $0.connected == true }) ?? false
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) return configured || running || connected || accountActive
else { return false }
return status.configured || status.running
case .discord:
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false }
return status.configured || status.running
case .signal:
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false }
return status.configured || status.running
case .imessage:
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return false }
return status.configured || status.running
}
} }
@ViewBuilder @ViewBuilder
func channelSection(_ channel: ConnectionChannel) -> some View { func channelSection(_ channel: ChannelItem) -> some View {
switch channel { if channel.id == "whatsapp" {
case .whatsapp:
self.whatsAppSection self.whatsAppSection
case .telegram: } else {
self.telegramSection self.genericChannelSection(channel)
case .discord:
self.discordSection
case .signal:
self.signalSection
case .imessage:
self.imessageSection
} }
} }
func channelTint(_ channel: ConnectionChannel) -> Color { func channelTint(_ channel: ChannelItem) -> Color {
switch channel { switch channel.id {
case .whatsapp: case "whatsapp":
self.whatsAppTint return self.whatsAppTint
case .telegram: case "telegram":
self.telegramTint return self.telegramTint
case .discord: case "discord":
self.discordTint return self.discordTint
case .signal: case "signal":
self.signalTint return self.signalTint
case .imessage: case "imessage":
self.imessageTint return self.imessageTint
default:
if self.channelHasError(channel) { return .orange }
if self.channelEnabled(channel) { return .green }
return .secondary
} }
} }
func channelSummary(_ channel: ConnectionChannel) -> String { func channelSummary(_ channel: ChannelItem) -> String {
switch channel { switch channel.id {
case .whatsapp: case "whatsapp":
self.whatsAppSummary return self.whatsAppSummary
case .telegram: case "telegram":
self.telegramSummary return self.telegramSummary
case .discord: case "discord":
self.discordSummary return self.discordSummary
case .signal: case "signal":
self.signalSummary return self.signalSummary
case .imessage: case "imessage":
self.imessageSummary return self.imessageSummary
default:
if self.channelHasError(channel) { return "Error" }
if self.channelEnabled(channel) { return "Active" }
return "Not configured"
} }
} }
func channelDetails(_ channel: ConnectionChannel) -> String? { func channelDetails(_ channel: ChannelItem) -> String? {
switch channel { switch channel.id {
case .whatsapp: case "whatsapp":
self.whatsAppDetails return self.whatsAppDetails
case .telegram: case "telegram":
self.telegramDetails return self.telegramDetails
case .discord: case "discord":
self.discordDetails return self.discordDetails
case .signal: case "signal":
self.signalDetails return self.signalDetails
case .imessage: case "imessage":
self.imessageDetails return self.imessageDetails
default:
let status = self.channelStatusDictionary(channel.id)
if let err = status?["lastError"]?.stringValue, !err.isEmpty {
return "Error: \(err)"
}
return nil
} }
} }
func channelLastCheckText(_ channel: ConnectionChannel) -> String { func channelLastCheckText(_ channel: ChannelItem) -> String {
guard let date = self.channelLastCheck(channel) else { return "never" } guard let date = self.channelLastCheck(channel) else { return "never" }
return relativeAge(from: date) return relativeAge(from: date)
} }
func channelLastCheck(_ channel: ConnectionChannel) -> Date? { func channelLastCheck(_ channel: ChannelItem) -> Date? {
switch channel { switch channel.id {
case .whatsapp: case "whatsapp":
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return nil } else { return nil }
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt) return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
case .telegram: case "telegram":
return self return self
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)? .date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.lastProbeAt) .lastProbeAt)
case .discord: case "discord":
return self return self
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)? .date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.lastProbeAt) .lastProbeAt)
case .signal: case "signal":
return self return self
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt) .date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
case .imessage: case "imessage":
return self return self
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)? .date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
.lastProbeAt) .lastProbeAt)
default:
let status = self.channelStatusDictionary(channel.id)
if let probeAt = status?["lastProbeAt"]?.doubleValue {
return self.date(fromMs: probeAt)
}
if let accounts = self.store.snapshot?.channelAccounts[channel.id] {
let last = accounts.compactMap { $0.lastInboundAt ?? $0.lastOutboundAt }.max()
return self.date(fromMs: last)
}
return nil
} }
} }
func channelHasError(_ channel: ConnectionChannel) -> Bool { func channelHasError(_ channel: ChannelItem) -> Bool {
switch channel { switch channel.id {
case .whatsapp: case "whatsapp":
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return false } else { return false }
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
case .telegram: case "telegram":
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return false } else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
case .discord: case "discord":
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false } else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
case .signal: case "signal":
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false } else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
case .imessage: case "imessage":
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return false } else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
default:
let status = self.channelStatusDictionary(channel.id)
return status?["lastError"]?.stringValue?.isEmpty == false
} }
} }
private func resolveChannelTitle(_ id: String) -> String {
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
return label
}
return id.prefix(1).uppercased() + id.dropFirst()
}
private func resolveChannelDetailTitle(_ id: String) -> String {
switch id {
case "whatsapp": return "WhatsApp Web"
case "telegram": return "Telegram Bot"
case "discord": return "Discord Bot"
case "slack": return "Slack Bot"
case "signal": return "Signal REST"
case "imessage": return "iMessage"
default: return self.resolveChannelTitle(id)
}
}
private func resolveChannelSystemImage(_ id: String) -> String {
switch id {
case "whatsapp": return "message"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "signal": return "antenna.radiowaves.left.and.right"
case "imessage": return "message.fill"
default: return "message"
}
}
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
self.store.snapshot?.channels[id]?.dictionaryValue
}
} }

View File

@ -1,6 +1,6 @@
import AppKit import AppKit
extension ConnectionsSettings { extension ChannelsSettings {
func date(fromMs ms: Double?) -> Date? { func date(fromMs ms: Double?) -> Date? {
guard let ms else { return nil } guard let ms else { return nil }
return Date(timeIntervalSince1970: ms / 1000) return Date(timeIntervalSince1970: ms / 1000)

View File

@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
extension ConnectionsSettings { extension ChannelsSettings {
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
self.sidebar self.sidebar
@ -57,7 +57,7 @@ extension ConnectionsSettings {
private var emptyDetail: some View { private var emptyDetail: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Connections") Text("Channels")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
Text("Select a channel to view status and settings.") Text("Select a channel to view status and settings.")
.font(.callout) .font(.callout)
@ -67,7 +67,7 @@ extension ConnectionsSettings {
.padding(.vertical, 18) .padding(.vertical, 18)
} }
private func channelDetail(_ channel: ConnectionChannel) -> some View { private func channelDetail(_ channel: ChannelItem) -> some View {
ScrollView(.vertical) { ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
self.detailHeader(for: channel) self.detailHeader(for: channel)
@ -81,7 +81,7 @@ extension ConnectionsSettings {
} }
} }
private func sidebarRow(_ channel: ConnectionChannel) -> some View { private func sidebarRow(_ channel: ChannelItem) -> some View {
let isSelected = self.selectedChannel == channel let isSelected = self.selectedChannel == channel
return Button { return Button {
self.selectedChannel = channel self.selectedChannel = channel
@ -119,7 +119,7 @@ extension ConnectionsSettings {
.padding(.top, 2) .padding(.top, 2)
} }
private func detailHeader(for channel: ConnectionChannel) -> some View { private func detailHeader(for channel: ChannelItem) -> some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 10) { HStack(alignment: .firstTextBaseline, spacing: 10) {
Label(channel.detailTitle, systemImage: channel.systemImage) Label(channel.detailTitle, systemImage: channel.systemImage)

View File

@ -0,0 +1,19 @@
import AppKit
import SwiftUI
struct ChannelsSettings: View {
struct ChannelItem: Identifiable, Hashable {
let id: String
let title: String
let detailTitle: String
let systemImage: String
let sortOrder: Int
}
@Bindable var store: ChannelsStore
@State var selectedChannel: ChannelItem?
init(store: ChannelsStore = .shared) {
self.store = store
}
}

View File

@ -0,0 +1,154 @@
import ClawdbotProtocol
import Foundation
extension ChannelsStore {
func loadConfigSchema() async {
guard !self.configSchemaLoading else { return }
self.configSchemaLoading = true
defer { self.configSchemaLoading = false }
do {
let res: ConfigSchemaResponse = try await GatewayConnection.shared.requestDecoded(
method: .configSchema,
params: nil,
timeoutMs: 8000)
let schemaValue = res.schema.foundationValue
self.configSchema = ConfigSchemaNode(raw: schemaValue)
let hintValues = res.uihints.mapValues { $0.foundationValue }
self.configUiHints = decodeUiHints(hintValues)
} catch {
self.configStatus = error.localizedDescription
}
}
func loadConfig() async {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
params: nil,
timeoutMs: 10000)
self.configStatus = snap.valid == false
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
: nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configDraft = cloneConfigValue(self.configRoot) as? [String: Any] ?? self.configRoot
self.configDirty = false
self.configLoaded = true
self.applyUIConfig(snap)
} catch {
self.configStatus = error.localizedDescription
}
}
private func applyUIConfig(_ snap: ConfigSnapshot) {
let ui = snap.config?["ui"]?.dictionaryValue
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
}
func channelConfigSchema(for channelId: String) -> ConfigSchemaNode? {
guard let root = self.configSchema else { return nil }
return root.node(at: [.key("channels"), .key(channelId)])
}
func configValue(at path: ConfigPath) -> Any? {
if let value = valueAtPath(self.configDraft, path: path) {
return value
}
guard path.count >= 2 else { return nil }
if case .key("channels") = path[0], case .key(_) = path[1] {
let fallbackPath = Array(path.dropFirst())
return valueAtPath(self.configDraft, path: fallbackPath)
}
return nil
}
func updateConfigValue(path: ConfigPath, value: Any?) {
var root: Any = self.configDraft
setValue(&root, path: path, value: value)
self.configDraft = root as? [String: Any] ?? self.configDraft
self.configDirty = true
}
func saveConfigDraft() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
do {
try await ConfigStore.save(self.configDraft)
await self.loadConfig()
} catch {
self.configStatus = error.localizedDescription
}
}
func reloadConfigDraft() async {
await self.loadConfig()
}
}
private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
var current: Any? = root
for segment in path {
switch segment {
case .key(let key):
guard let dict = current as? [String: Any] else { return nil }
current = dict[key]
case .index(let index):
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
current = array[index]
}
}
return current
}
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
guard let segment = path.first else { return }
switch segment {
case .key(let key):
var dict = root as? [String: Any] ?? [:]
if path.count == 1 {
if let value {
dict[key] = value
} else {
dict.removeValue(forKey: key)
}
root = dict
return
}
var child = dict[key] ?? [:]
setValue(&child, path: Array(path.dropFirst()), value: value)
dict[key] = child
root = dict
case .index(let index):
var array = root as? [Any] ?? []
if index >= array.count {
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
}
if path.count == 1 {
if let value {
array[index] = value
} else if array.indices.contains(index) {
array.remove(at: index)
}
root = array
return
}
var child = array[index]
setValue(&child, path: Array(path.dropFirst()), value: value)
array[index] = child
root = array
}
}
private func cloneConfigValue(_ value: Any) -> Any {
guard JSONSerialization.isValidJSONObject(value) else { return value }
do {
let data = try JSONSerialization.data(withJSONObject: value, options: [])
return try JSONSerialization.jsonObject(with: data, options: [])
} catch {
return value
}
}

View File

@ -1,13 +1,14 @@
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
extension ConnectionsStore { extension ChannelsStore {
func start() { func start() {
guard !self.isPreview else { return } guard !self.isPreview else { return }
guard self.pollTask == nil else { return } guard self.pollTask == nil else { return }
self.pollTask = Task.detached { [weak self] in self.pollTask = Task.detached { [weak self] in
guard let self else { return } guard let self else { return }
await self.refresh(probe: true) await self.refresh(probe: true)
await self.loadConfigSchema()
await self.loadConfig() await self.loadConfig()
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))

View File

@ -187,49 +187,10 @@ struct ConfigSnapshot: Codable {
let issues: [Issue]? let issues: [Issue]?
} }
struct DiscordGuildChannelForm: Identifiable {
let id = UUID()
var key: String
var allow: Bool
var requireMention: Bool
init(key: String = "", allow: Bool = true, requireMention: Bool = false) {
self.key = key
self.allow = allow
self.requireMention = requireMention
}
}
struct DiscordGuildForm: Identifiable {
let id = UUID()
var key: String
var slug: String
var requireMention: Bool
var reactionNotifications: String
var users: String
var channels: [DiscordGuildChannelForm]
init(
key: String = "",
slug: String = "",
requireMention: Bool = false,
reactionNotifications: String = "own",
users: String = "",
channels: [DiscordGuildChannelForm] = [])
{
self.key = key
self.slug = slug
self.requireMention = requireMention
self.reactionNotifications = reactionNotifications
self.users = users
self.channels = channels
}
}
@MainActor @MainActor
@Observable @Observable
final class ConnectionsStore { final class ChannelsStore {
static let shared = ConnectionsStore() static let shared = ChannelsStore()
var snapshot: ChannelsStatusSnapshot? var snapshot: ChannelsStatusSnapshot?
var lastError: String? var lastError: String?
@ -240,75 +201,21 @@ final class ConnectionsStore {
var whatsappLoginQrDataUrl: String? var whatsappLoginQrDataUrl: String?
var whatsappLoginConnected: Bool? var whatsappLoginConnected: Bool?
var whatsappBusy = false var whatsappBusy = false
var telegramToken: String = ""
var telegramRequireMention = true
var telegramAllowFrom: String = ""
var telegramProxy: String = ""
var telegramWebhookUrl: String = ""
var telegramWebhookSecret: String = ""
var telegramWebhookPath: String = ""
var telegramBusy = false var telegramBusy = false
var discordEnabled = true
var discordToken: String = ""
var discordDmEnabled = true
var discordAllowFrom: String = ""
var discordGroupEnabled = false
var discordGroupChannels: String = ""
var discordMediaMaxMb: String = ""
var discordHistoryLimit: String = ""
var discordTextChunkLimit: String = ""
var discordReplyToMode: String = "off"
var discordGuilds: [DiscordGuildForm] = []
var discordActionReactions = true
var discordActionStickers = true
var discordActionPolls = true
var discordActionPermissions = true
var discordActionMessages = true
var discordActionThreads = true
var discordActionPins = true
var discordActionSearch = true
var discordActionMemberInfo = true
var discordActionRoleInfo = true
var discordActionChannelInfo = true
var discordActionVoiceStatus = true
var discordActionEvents = true
var discordActionRoles = false
var discordActionModeration = false
var discordSlashEnabled = false
var discordSlashName: String = ""
var discordSlashSessionPrefix: String = ""
var discordSlashEphemeral = true
var signalEnabled = true
var signalAccount: String = ""
var signalHttpUrl: String = ""
var signalHttpHost: String = ""
var signalHttpPort: String = ""
var signalCliPath: String = ""
var signalAutoStart = true
var signalReceiveMode: String = ""
var signalIgnoreAttachments = false
var signalIgnoreStories = false
var signalSendReadReceipts = false
var signalAllowFrom: String = ""
var signalMediaMaxMb: String = ""
var imessageEnabled = true
var imessageCliPath: String = ""
var imessageDbPath: String = ""
var imessageService: String = "auto"
var imessageRegion: String = ""
var imessageAllowFrom: String = ""
var imessageIncludeAttachments = false
var imessageMediaMaxMb: String = ""
var configStatus: String? var configStatus: String?
var isSavingConfig = false var isSavingConfig = false
var configSchemaLoading = false
var configSchema: ConfigSchemaNode?
var configUiHints: [String: ConfigUiHint] = [:]
var configDraft: [String: Any] = [:]
var configDirty = false
let interval: TimeInterval = 45 let interval: TimeInterval = 45
let isPreview: Bool let isPreview: Bool
var pollTask: Task<Void, Never>? var pollTask: Task<Void, Never>?
var configRoot: [String: Any] = [:] var configRoot: [String: Any] = [:]
var configLoaded = false var configLoaded = false
var configHash: String?
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview self.isPreview = isPreview

View File

@ -0,0 +1,204 @@
import Foundation
enum ConfigPathSegment: Hashable {
case key(String)
case index(Int)
}
typealias ConfigPath = [ConfigPathSegment]
struct ConfigUiHint {
let label: String?
let help: String?
let order: Double?
let advanced: Bool?
let sensitive: Bool?
let placeholder: String?
init(raw: [String: Any]) {
self.label = raw["label"] as? String
self.help = raw["help"] as? String
if let order = raw["order"] as? Double {
self.order = order
} else if let orderInt = raw["order"] as? Int {
self.order = Double(orderInt)
} else {
self.order = nil
}
self.advanced = raw["advanced"] as? Bool
self.sensitive = raw["sensitive"] as? Bool
self.placeholder = raw["placeholder"] as? String
}
}
struct ConfigSchemaNode {
let raw: [String: Any]
init?(raw: Any) {
guard let dict = raw as? [String: Any] else { return nil }
self.raw = dict
}
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<String> {
Set((self.raw["required"] as? [String]) ?? [])
}
var typeList: [String] {
if let type = self.raw["type"] as? String { return [type] }
if let types = self.raw["type"] as? [String] { return types }
return []
}
var schemaType: String? {
let filtered = self.typeList.filter { $0 != "null" }
if let first = filtered.first { return first }
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)
}
if let items = self.raw["items"] {
return ConfigSchemaNode(raw: items)
}
return nil
}
var additionalProperties: ConfigSchemaNode? {
if let additional = self.raw["additionalProperties"] as? [String: Any] {
return ConfigSchemaNode(raw: additional)
}
return nil
}
var allowsAdditionalProperties: Bool {
if let allow = self.raw["additionalProperties"] as? Bool { return allow }
return self.additionalProperties != nil
}
var defaultValue: Any {
if let value = self.raw["default"] { return value }
switch self.schemaType {
case "object":
return [String: Any]()
case "array":
return [Any]()
case "boolean":
return false
case "integer":
return 0
case "number":
return 0.0
case "string":
return ""
default:
return ""
}
}
func node(at path: ConfigPath) -> ConfigSchemaNode? {
var current: ConfigSchemaNode? = self
for segment in path {
guard let node = current else { return nil }
switch segment {
case .key(let key):
if node.schemaType == "object" {
if let next = node.properties[key] {
current = next
continue
}
if let additional = node.additionalProperties {
current = additional
continue
}
return nil
}
return nil
case .index:
guard node.schemaType == "array" else { return nil }
current = node.items
}
}
return current
}
}
func decodeUiHints(_ raw: [String: Any]) -> [String: ConfigUiHint] {
raw.reduce(into: [:]) { result, entry in
if let hint = entry.value as? [String: Any] {
result[entry.key] = ConfigUiHint(raw: hint)
}
}
}
func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiHint? {
let key = pathKey(path)
if let direct = hints[key] { return direct }
let segments = key.split(separator: ".").map(String.init)
for (hintKey, hint) in hints {
guard hintKey.contains("*") else { continue }
let hintSegments = hintKey.split(separator: ".").map(String.init)
guard hintSegments.count == segments.count else { continue }
var match = true
for (index, seg) in segments.enumerated() {
let hintSegment = hintSegments[index]
if hintSegment != "*" && hintSegment != seg {
match = false
break
}
}
if match { return hint }
}
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 {
case .key(let key): return key
case .index: return nil
}
}
.joined(separator: ".")
}

View File

@ -4,86 +4,54 @@ import SwiftUI
struct ConfigSettings: View { struct ConfigSettings: View {
private let isPreview = ProcessInfo.processInfo.isPreview private let isPreview = ProcessInfo.processInfo.isPreview
private let isNixMode = ProcessInfo.processInfo.isNixMode private let isNixMode = ProcessInfo.processInfo.isNixMode
private let state = AppStateStore.shared @Bindable var store: ChannelsStore
private let labelColumnWidth: CGFloat = 120
private static let browserAttachOnlyHelp =
"When enabled, the browser server will only connect if the clawd browser is already running."
private static let browserProfileNote =
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
+ "so it wont interfere with your daily browser."
@State private var configModel: String = ""
@State private var configSaving = false
@State private var hasLoaded = false @State private var hasLoaded = false
@State private var models: [ModelChoice] = []
@State private var modelsLoading = false
@State private var modelSearchQuery: String = ""
@State private var isModelPickerOpen = false
@State private var modelError: String?
@State private var modelsSourceLabel: String?
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
@State private var allowAutosave = false
@State private var heartbeatMinutes: Int?
@State private var heartbeatBody: String = "HEARTBEAT"
// clawd browser settings (stored in ~/.clawdbot/clawdbot.json under "browser") init(store: ChannelsStore = .shared) {
@State private var browserEnabled: Bool = true self.store = store
@State private var browserControlUrl: String = "http://127.0.0.1:18791"
@State private var browserColorHex: String = "#FF4500"
@State private var browserAttachOnly: Bool = false
// Talk mode settings (stored in ~/.clawdbot/clawdbot.json under "talk")
@State private var talkVoiceId: String = ""
@State private var talkInterruptOnSpeech: Bool = true
@State private var talkApiKey: String = ""
@State private var gatewayApiKeyFound = false
@FocusState private var modelSearchFocused: Bool
private struct ConfigDraft {
let configModel: String
let heartbeatMinutes: Int?
let heartbeatBody: String
let browserEnabled: Bool
let browserControlUrl: String
let browserColorHex: String
let browserAttachOnly: Bool
let talkVoiceId: String
let talkApiKey: String
let talkInterruptOnSpeech: Bool
} }
var body: some View { var body: some View {
ScrollView { self.content } ScrollView {
.onChange(of: self.modelCatalogPath) { _, _ in self.content
Task { await self.loadModels() } }
} .task {
.onChange(of: self.modelCatalogReloadBump) { _, _ in guard !self.hasLoaded else { return }
Task { await self.loadModels() } guard !self.isPreview else { return }
} self.hasLoaded = true
.task { await self.store.loadConfigSchema()
guard !self.hasLoaded else { return } await self.store.loadConfig()
guard !self.isPreview else { return } }
self.hasLoaded = true
await self.loadConfig()
await self.loadModels()
await self.refreshGatewayTalkApiKey()
self.allowAutosave = true
}
} }
} }
extension ConfigSettings { extension ConfigSettings {
private var content: some View { private var content: some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 16) {
self.header self.header
self.agentSection if let status = self.store.configStatus {
.disabled(self.isNixMode) Text(status)
self.heartbeatSection .font(.callout)
.disabled(self.isNixMode) .foregroundStyle(.secondary)
self.talkSection }
.disabled(self.isNixMode) self.actionRow
self.browserSection Group {
.disabled(self.isNixMode) if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let schema = self.store.configSchema {
ConfigSchemaForm(store: self.store, schema: schema, path: [])
.disabled(self.isNixMode)
} else {
Text("Schema unavailable.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if self.store.configDirty && !self.isNixMode {
Text("Unsaved changes")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@ -94,843 +62,33 @@ extension ConfigSettings {
@ViewBuilder @ViewBuilder
private var header: some View { private var header: some View {
Text("Clawdbot CLI config") Text("Config")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
Text(self.isNixMode Text(self.isNixMode
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild." ? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).") : "Edit ~/.clawdbot/clawdbot.json using the schema-driven form.")
.font(.callout) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
private var agentSection: some View { private var actionRow: some View {
GroupBox("Agent") { HStack(spacing: 10) {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { Button("Reload") {
GridRow { Task { await self.store.reloadConfigDraft() }
self.gridLabel("Model")
VStack(alignment: .leading, spacing: 6) {
self.modelPickerField
self.modelMetaLabels
}
}
} }
} .disabled(!self.store.configLoaded)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var modelPickerField: some View { Button(self.store.isSavingConfig ? "Saving…" : "Save") {
Button { Task { await self.store.saveConfigDraft() }
guard !self.modelsLoading else { return }
self.isModelPickerOpen = true
} label: {
HStack(spacing: 8) {
Text(self.modelPickerLabel)
.foregroundStyle(self.modelPickerLabelIsPlaceholder ? .secondary : .primary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 8)
Image(systemName: "chevron.up.chevron.down")
.foregroundStyle(.secondary)
} }
.padding(.vertical, 6) .disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configDirty)
.padding(.horizontal, 8)
} }
.buttonStyle(.plain) .buttonStyle(.bordered)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 6)
.fill(
Color(nsColor: .textBackgroundColor)))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(
Color.secondary.opacity(0.25),
lineWidth: 1))
.popover(isPresented: self.$isModelPickerOpen, arrowEdge: .bottom) {
self.modelPickerPopover
}
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
.onChange(of: self.isModelPickerOpen) { _, isOpen in
if isOpen {
self.modelSearchQuery = ""
self.modelSearchFocused = true
}
}
}
private var modelPickerPopover: some View {
VStack(alignment: .leading, spacing: 10) {
TextField("Search models", text: self.$modelSearchQuery)
.textFieldStyle(.roundedBorder)
.focused(self.$modelSearchFocused)
.controlSize(.small)
.onSubmit {
if let exact = self.exactMatchForQuery() {
self.selectModel(exact)
return
}
if let manual = self.manualEntryCandidate {
self.selectManualModel(manual)
return
}
if self.modelSearchMatches.count == 1 {
self.selectModel(self.modelSearchMatches[0])
}
}
List {
if self.modelSearchMatches.isEmpty {
Text("No models match \"\(self.modelSearchQuery)\"")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(self.modelSearchMatches) { choice in
Button {
self.selectModel(choice)
} label: {
HStack(spacing: 8) {
Text(choice.name)
.lineLimit(1)
Spacer(minLength: 8)
Text(choice.provider.uppercased())
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.vertical, 2)
.padding(.horizontal, 6)
.background(Color.secondary.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
.padding(.vertical, 2)
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
}
}
if let manual = self.manualEntryCandidate {
Button("Use \"\(manual)\"") {
self.selectManualModel(manual)
}
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
}
}
.listStyle(.inset)
}
.frame(width: 340, height: 260)
.padding(8)
}
@ViewBuilder
private var modelMetaLabels: some View {
if self.shouldShowProviderHintForSelection {
self.statusLine(label: "Tip: prefer provider/model (e.g. openai-codex/gpt-5.2)", color: .orange)
}
if let contextLabel = self.selectedContextLabel {
Text(contextLabel)
.font(.footnote)
.foregroundStyle(.secondary)
}
if let authMode = self.selectedAnthropicAuthMode {
HStack(spacing: 8) {
Circle()
.fill(authMode.isConfigured ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text("Anthropic auth: \(authMode.shortLabel)")
}
.font(.footnote)
.foregroundStyle(authMode.isConfigured ? Color.secondary : Color.orange)
.help(self.anthropicAuthHelpText)
AnthropicAuthControls(connectionMode: self.state.connectionMode)
}
if let modelError {
Text(modelError)
.font(.footnote)
.foregroundStyle(.secondary)
}
if let modelsSourceLabel {
Text("Model catalog: \(modelsSourceLabel)")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private var anthropicAuthHelpText: String {
"Determined from Clawdbot OAuth token file (~/.clawdbot/credentials/oauth.json) " +
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
}
private var heartbeatSection: some View {
GroupBox("Heartbeat") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Schedule")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) {
Stepper(
value: Binding(
get: { self.heartbeatMinutes ?? 10 },
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
in: 0...720)
{
Text("Every \(self.heartbeatMinutes ?? 10) min")
.frame(width: 150, alignment: .leading)
}
.help("Set to 0 to disable automatic heartbeats")
TextField("HEARTBEAT", text: self.$heartbeatBody)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.heartbeatBody) { _, _ in
self.autosaveConfig()
}
.help("Message body sent on each heartbeat")
}
Text("Heartbeats keep agent sessions warm; 0 minutes disables them.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var browserSection: some View {
GroupBox("Browser (clawd)") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$browserEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Control URL")
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.disabled(!self.browserEnabled)
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Browser path")
VStack(alignment: .leading, spacing: 2) {
if let label = self.browserPathLabel {
Text(label)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
} else {
Text("")
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Accent")
HStack(spacing: 8) {
TextField("#FF4500", text: self.$browserColorHex)
.textFieldStyle(.roundedBorder)
.frame(width: 120)
.disabled(!self.browserEnabled)
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
Circle()
.fill(self.browserColor)
.frame(width: 12, height: 12)
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
Text("lobster-orange")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
GridRow {
self.gridLabel("Attach only")
Toggle("", isOn: self.$browserAttachOnly)
.labelsHidden()
.toggleStyle(.checkbox)
.disabled(!self.browserEnabled)
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
.help(Self.browserAttachOnlyHelp)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(Self.browserProfileNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var talkSection: some View {
GroupBox("Talk Mode") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Voice ID")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
TextField("ElevenLabs voice ID", text: self.$talkVoiceId)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.talkVoiceId) { _, _ in self.autosaveConfig() }
if !self.talkVoiceSuggestions.isEmpty {
Menu {
ForEach(self.talkVoiceSuggestions, id: \.self) { value in
Button(value) {
self.talkVoiceId = value
self.autosaveConfig()
}
}
} label: {
Label("Suggestions", systemImage: "chevron.up.chevron.down")
}
.fixedSize()
}
}
Text("Defaults to ELEVENLABS_VOICE_ID / SAG_VOICE_ID if unset.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
GridRow {
self.gridLabel("API key")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
SecureField("ELEVENLABS_API_KEY", text: self.$talkApiKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.disabled(self.hasEnvApiKey)
.onChange(of: self.talkApiKey) { _, _ in self.autosaveConfig() }
if !self.hasEnvApiKey, !self.talkApiKey.isEmpty {
Button("Clear") {
self.talkApiKey = ""
self.autosaveConfig()
}
}
}
self.statusLine(label: self.apiKeyStatusLabel, color: self.apiKeyStatusColor)
if self.hasEnvApiKey {
Text("Using ELEVENLABS_API_KEY from the environment.")
.font(.footnote)
.foregroundStyle(.secondary)
} else if self.gatewayApiKeyFound,
self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
Text("Using API key from the gateway profile.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
GridRow {
self.gridLabel("Interrupt")
Toggle("Stop speaking when you start talking", isOn: self.$talkInterruptOnSpeech)
.labelsHidden()
.toggleStyle(.checkbox)
.onChange(of: self.talkInterruptOnSpeech) { _, _ in self.autosaveConfig() }
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func gridLabel(_ text: String) -> some View {
Text(text)
.foregroundStyle(.secondary)
.frame(width: self.labelColumnWidth, alignment: .leading)
}
private func statusLine(label: String, color: Color) -> some View {
HStack(spacing: 6) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
Text(label)
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.top, 2)
} }
} }
extension ConfigSettings {
private func loadConfig() async {
let parsed = await ConfigStore.load()
let agents = parsed["agents"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any]
let heartbeat = defaults?["heartbeat"] as? [String: Any]
let heartbeatEvery = heartbeat?["every"] as? String
let heartbeatBody = heartbeat?["prompt"] as? String
let browser = parsed["browser"] as? [String: Any]
let talk = parsed["talk"] as? [String: Any]
let loadedModel: String = {
if let raw = defaults?["model"] as? String { return raw }
if let modelDict = defaults?["model"] as? [String: Any],
let primary = modelDict["primary"] as? String { return primary }
return ""
}()
if !loadedModel.isEmpty {
self.configModel = loadedModel
} else {
self.configModel = SessionLoader.fallbackModel
}
if let heartbeatEvery {
let digits = heartbeatEvery.trimmingCharacters(in: .whitespacesAndNewlines)
.prefix { $0.isNumber }
if let minutes = Int(digits) {
self.heartbeatMinutes = minutes
}
}
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
if let browser {
if let enabled = browser["enabled"] as? Bool { self.browserEnabled = enabled }
if let url = browser["controlUrl"] as? String, !url.isEmpty { self.browserControlUrl = url }
if let color = browser["color"] as? String, !color.isEmpty { self.browserColorHex = color }
if let attachOnly = browser["attachOnly"] as? Bool { self.browserAttachOnly = attachOnly }
}
if let talk {
if let voice = talk["voiceId"] as? String { self.talkVoiceId = voice }
if let apiKey = talk["apiKey"] as? String { self.talkApiKey = apiKey }
if let interrupt = talk["interruptOnSpeech"] as? Bool {
self.talkInterruptOnSpeech = interrupt
}
}
}
private func refreshGatewayTalkApiKey() async {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
params: nil,
timeoutMs: 8000)
let talk = snap.config?["talk"]?.dictionaryValue
let apiKey = talk?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
self.gatewayApiKeyFound = !(apiKey ?? "").isEmpty
} catch {
self.gatewayApiKeyFound = false
}
}
private func autosaveConfig() {
guard self.allowAutosave, !self.isNixMode else { return }
Task { await self.saveConfig() }
}
private func saveConfig() async {
guard !self.configSaving else { return }
self.configSaving = true
defer { self.configSaving = false }
let configModel = self.configModel
let heartbeatMinutes = self.heartbeatMinutes
let heartbeatBody = self.heartbeatBody
let browserEnabled = self.browserEnabled
let browserControlUrl = self.browserControlUrl
let browserColorHex = self.browserColorHex
let browserAttachOnly = self.browserAttachOnly
let talkVoiceId = self.talkVoiceId
let talkApiKey = self.talkApiKey
let talkInterruptOnSpeech = self.talkInterruptOnSpeech
let draft = ConfigDraft(
configModel: configModel,
heartbeatMinutes: heartbeatMinutes,
heartbeatBody: heartbeatBody,
browserEnabled: browserEnabled,
browserControlUrl: browserControlUrl,
browserColorHex: browserColorHex,
browserAttachOnly: browserAttachOnly,
talkVoiceId: talkVoiceId,
talkApiKey: talkApiKey,
talkInterruptOnSpeech: talkInterruptOnSpeech)
let errorMessage = await ConfigSettings.buildAndSaveConfig(draft)
if let errorMessage {
self.modelError = errorMessage
}
}
@MainActor
private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? {
var root = await ConfigStore.load()
var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
var browser = root["browser"] as? [String: Any] ?? [:]
var talk = root["talk"] as? [String: Any] ?? [:]
let chosenModel = draft.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedModel = chosenModel
if !trimmedModel.isEmpty {
var model = defaults["model"] as? [String: Any] ?? [:]
model["primary"] = trimmedModel
defaults["model"] = model
var models = defaults["models"] as? [String: Any] ?? [:]
if models[trimmedModel] == nil {
models[trimmedModel] = [:]
}
defaults["models"] = models
}
if let heartbeatMinutes = draft.heartbeatMinutes {
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
heartbeat["every"] = "\(heartbeatMinutes)m"
defaults["heartbeat"] = heartbeat
}
let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedBody.isEmpty {
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
heartbeat["prompt"] = trimmedBody
defaults["heartbeat"] = heartbeat
}
if defaults.isEmpty {
agents.removeValue(forKey: "defaults")
} else {
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
}
browser["enabled"] = draft.browserEnabled
let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl }
let trimmedColor = draft.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedColor.isEmpty { browser["color"] = trimmedColor }
browser["attachOnly"] = draft.browserAttachOnly
root["browser"] = browser
let trimmedVoice = draft.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedVoice.isEmpty {
talk.removeValue(forKey: "voiceId")
} else {
talk["voiceId"] = trimmedVoice
}
let trimmedApiKey = draft.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedApiKey.isEmpty {
talk.removeValue(forKey: "apiKey")
} else {
talk["apiKey"] = trimmedApiKey
}
talk["interruptOnSpeech"] = draft.talkInterruptOnSpeech
root["talk"] = talk
do {
try await ConfigStore.save(root)
return nil
} catch {
return error.localizedDescription
}
}
}
extension ConfigSettings {
private var browserColor: Color {
let raw = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
let hex = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw
guard hex.count == 6, let value = Int(hex, radix: 16) else { return .orange }
let r = Double((value >> 16) & 0xFF) / 255.0
let g = Double((value >> 8) & 0xFF) / 255.0
let b = Double(value & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
private var talkVoiceSuggestions: [String] {
let env = ProcessInfo.processInfo.environment
let candidates = [
self.talkVoiceId,
env["ELEVENLABS_VOICE_ID"] ?? "",
env["SAG_VOICE_ID"] ?? "",
]
var seen = Set<String>()
return candidates
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.filter { seen.insert($0).inserted }
}
private var hasEnvApiKey: Bool {
let raw = ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] ?? ""
return !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var apiKeyStatusLabel: String {
if self.hasEnvApiKey { return "ElevenLabs API key: found (environment)" }
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "ElevenLabs API key: stored in config"
}
if self.gatewayApiKeyFound { return "ElevenLabs API key: found (gateway)" }
return "ElevenLabs API key: missing"
}
private var apiKeyStatusColor: Color {
if self.hasEnvApiKey { return .green }
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return .green }
if self.gatewayApiKeyFound { return .green }
return .red
}
private var browserPathLabel: String? {
guard self.browserEnabled else { return nil }
let host = (URL(string: self.browserControlUrl)?.host ?? "").lowercased()
if !host.isEmpty, !Self.isLoopbackHost(host) {
return "remote (\(host))"
}
guard let candidate = Self.detectedBrowserCandidate() else { return nil }
return candidate.executablePath ?? candidate.appPath
}
private struct BrowserCandidate {
let name: String
let appPath: String
let executablePath: String?
}
private static func detectedBrowserCandidate() -> BrowserCandidate? {
let candidates: [(name: String, appName: String)] = [
("Google Chrome Canary", "Google Chrome Canary.app"),
("Chromium", "Chromium.app"),
("Google Chrome", "Google Chrome.app"),
]
let roots = [
"/Applications",
"\(NSHomeDirectory())/Applications",
]
let fm = FileManager.default
for (name, appName) in candidates {
for root in roots {
let appPath = "\(root)/\(appName)"
if fm.fileExists(atPath: appPath) {
let bundle = Bundle(url: URL(fileURLWithPath: appPath))
let exec = bundle?.executableURL?.path
return BrowserCandidate(name: name, appPath: appPath, executablePath: exec)
}
}
}
return nil
}
private static func isLoopbackHost(_ host: String) -> Bool {
if host == "localhost" { return true }
if host == "127.0.0.1" { return true }
if host == "::1" { return true }
return false
}
}
extension ConfigSettings {
private func loadModels() async {
guard !self.modelsLoading else { return }
self.modelsLoading = true
self.modelError = nil
self.modelsSourceLabel = nil
do {
let res: ModelsListResult =
try await GatewayConnection.shared
.requestDecoded(
method: .modelsList,
timeoutMs: 15000)
self.models = res.models
self.modelsSourceLabel = "gateway"
} catch {
do {
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
self.models = loaded
self.modelsSourceLabel = "local fallback"
} catch {
self.modelError = error.localizedDescription
self.models = []
}
}
self.modelsLoading = false
}
private struct ModelsListResult: Decodable {
let models: [ModelChoice]
}
private var modelSearchMatches: [ModelChoice] {
let raw = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !raw.isEmpty else { return self.models }
let tokens = raw
.split(whereSeparator: { $0.isWhitespace })
.map { token in
token.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
}
.filter { !$0.isEmpty }
guard !tokens.isEmpty else { return self.models }
return self.models.filter { choice in
let haystack = [
choice.id,
choice.name,
choice.provider,
self.modelRef(for: choice),
]
.joined(separator: " ")
.lowercased()
return tokens.allSatisfy { haystack.contains($0) }
}
}
private var selectedModelChoice: ModelChoice? {
guard !self.configModel.isEmpty else { return nil }
return self.models.first(where: { self.matchesConfigModel($0) })
}
private var modelPickerLabel: String {
if let choice = self.selectedModelChoice {
return "\(choice.name)\(choice.provider.uppercased())"
}
if !self.configModel.isEmpty { return self.configModel }
return "Select model"
}
private var modelPickerLabelIsPlaceholder: Bool {
self.configModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var manualEntryCandidate: String? {
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
guard !cleaned.isEmpty else { return nil }
guard !self.isKnownModelRef(cleaned) else { return nil }
return cleaned
}
private func isKnownModelRef(_ value: String) -> Bool {
let needle = value.lowercased()
return self.models.contains { choice in
choice.id.lowercased() == needle
|| self.modelRef(for: choice).lowercased() == needle
}
}
private func modelRef(for choice: ModelChoice) -> String {
let id = choice.id.trimmingCharacters(in: .whitespacesAndNewlines)
let provider = choice.provider.trimmingCharacters(in: .whitespacesAndNewlines)
guard !provider.isEmpty else { return id }
let normalizedProvider = provider.lowercased()
if id.lowercased().hasPrefix("\(normalizedProvider)/") {
return id
}
return "\(normalizedProvider)/\(id)"
}
private func matchesConfigModel(_ choice: ModelChoice) -> Bool {
let configured = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
guard !configured.isEmpty else { return false }
if configured.caseInsensitiveCompare(choice.id) == .orderedSame { return true }
let ref = self.modelRef(for: choice)
return configured.caseInsensitiveCompare(ref) == .orderedSame
}
private func exactMatchForQuery() -> ModelChoice? {
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%")).lowercased()
guard !cleaned.isEmpty else { return nil }
return self.models.first(where: { choice in
let id = choice.id.lowercased()
if id == cleaned { return true }
return self.modelRef(for: choice).lowercased() == cleaned
})
}
private var shouldShowProviderHint: Bool {
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
return !cleaned.contains("/")
}
private var shouldShowProviderHintForSelection: Bool {
let trimmed = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
return !trimmed.contains("/")
}
private func selectModel(_ choice: ModelChoice) {
self.configModel = self.modelRef(for: choice)
self.autosaveConfig()
self.isModelPickerOpen = false
}
private func selectManualModel(_ value: String) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let slash = trimmed.firstIndex(of: "/") {
let provider = trimmed[..<slash].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let model = trimmed[trimmed.index(after: slash)...].trimmingCharacters(in: .whitespacesAndNewlines)
self.configModel = provider.isEmpty ? String(model) : "\(provider)/\(model)"
} else {
self.configModel = trimmed
}
self.autosaveConfig()
self.isModelPickerOpen = false
}
private var selectedContextLabel: String? {
guard
let choice = self.selectedModelChoice,
let context = choice.contextWindow
else {
return nil
}
let human = context >= 1000 ? "\(context / 1000)k" : "\(context)"
return "Context window: \(human) tokens"
}
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
guard let choice = self.selectedModelChoice else { return nil }
guard choice.provider.lowercased() == "anthropic" else { return nil }
return AnthropicAuthResolver.resolve()
}
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 10) {
configuration.label
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
configuration.content
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
#if DEBUG
struct ConfigSettings_Previews: PreviewProvider { struct ConfigSettings_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ConfigSettings() ConfigSettings()
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
} }
} }
#endif

View File

@ -1,707 +0,0 @@
import SwiftUI
extension ConnectionsSettings {
func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
GroupBox(title) {
VStack(alignment: .leading, spacing: 10) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder
func channelHeaderActions(_ channel: ConnectionChannel) -> some View {
HStack(spacing: 8) {
if channel == .whatsapp {
Button("Logout") {
Task { await self.store.logoutWhatsApp() }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
}
if channel == .telegram {
Button("Logout") {
Task { await self.store.logoutTelegram() }
}
.buttonStyle(.bordered)
.disabled(self.store.telegramBusy)
}
Button {
Task { await self.store.refresh(probe: true) }
} label: {
if self.store.isRefreshing {
ProgressView().controlSize(.small)
} else {
Text("Refresh")
}
}
.buttonStyle(.bordered)
.disabled(self.store.isRefreshing)
}
.controlSize(.small)
}
var whatsAppSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Linking") {
if let message = self.store.whatsappLoginMessage {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
Image(nsImage: image)
.resizable()
.interpolation(.none)
.frame(width: 180, height: 180)
.cornerRadius(8)
}
HStack(spacing: 12) {
Button {
Task { await self.store.startWhatsAppLogin(force: false) }
} label: {
if self.store.whatsappBusy {
ProgressView().controlSize(.small)
} else {
Text("Show QR")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.whatsappBusy)
Button("Relink") {
Task { await self.store.startWhatsAppLogin(force: true) }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
}
.font(.caption)
}
}
}
var telegramSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Authentication") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Bot token")
if self.showTelegramToken {
TextField("123:abc", text: self.$store.telegramToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isTelegramTokenLocked)
} else {
SecureField("123:abc", text: self.$store.telegramToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isTelegramTokenLocked)
}
Toggle("Show", isOn: self.$showTelegramToken)
.toggleStyle(.switch)
.disabled(self.isTelegramTokenLocked)
}
}
}
self.formSection("Access") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Require mention")
Toggle("", isOn: self.$store.telegramRequireMention)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Allow from")
TextField("123456789, @team", text: self.$store.telegramAllowFrom)
.textFieldStyle(.roundedBorder)
}
}
}
self.formSection("Webhook") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Webhook URL")
TextField("https://example.com/telegram-webhook", text: self.$store.telegramWebhookUrl)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Webhook secret")
TextField("secret", text: self.$store.telegramWebhookSecret)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Webhook path")
TextField("/telegram-webhook", text: self.$store.telegramWebhookPath)
.textFieldStyle(.roundedBorder)
}
}
}
self.formSection("Network") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Proxy")
TextField("socks5://localhost:9050", text: self.$store.telegramProxy)
.textFieldStyle(.roundedBorder)
}
}
}
if self.isTelegramTokenLocked {
Text("Token set via TELEGRAM_BOT_TOKEN env; config edits wont override it.")
.font(.caption)
.foregroundStyle(.secondary)
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveTelegramConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
}
var discordSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Authentication") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$store.discordEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Bot token")
if self.showDiscordToken {
TextField("bot token", text: self.$store.discordToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isDiscordTokenLocked)
} else {
SecureField("bot token", text: self.$store.discordToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isDiscordTokenLocked)
}
Toggle("Show", isOn: self.$showDiscordToken)
.toggleStyle(.switch)
.disabled(self.isDiscordTokenLocked)
}
}
}
self.formSection("Messages") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Allow DMs from")
TextField("123456789, username#1234", text: self.$store.discordAllowFrom)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("DMs enabled")
Toggle("", isOn: self.$store.discordDmEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Group DMs")
Toggle("", isOn: self.$store.discordGroupEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Group channels")
TextField("channelId1, channelId2", text: self.$store.discordGroupChannels)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Reply to mode")
Picker("", selection: self.$store.discordReplyToMode) {
Text("off").tag("off")
Text("first").tag("first")
Text("all").tag("all")
}
.labelsHidden()
}
}
}
self.formSection("Limits") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Media max MB")
TextField("8", text: self.$store.discordMediaMaxMb)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("History limit")
TextField("20", text: self.$store.discordHistoryLimit)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Text chunk limit")
TextField("2000", text: self.$store.discordTextChunkLimit)
.textFieldStyle(.roundedBorder)
}
}
}
self.formSection("Slash command") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$store.discordSlashEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Slash name")
TextField("clawd", text: self.$store.discordSlashName)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Session prefix")
TextField("discord:slash", text: self.$store.discordSlashSessionPrefix)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Ephemeral")
Toggle("", isOn: self.$store.discordSlashEphemeral)
.labelsHidden()
.toggleStyle(.checkbox)
}
}
}
GroupBox("Guilds") {
VStack(alignment: .leading, spacing: 12) {
ForEach(self.$store.discordGuilds) { $guild in
VStack(alignment: .leading, spacing: 10) {
HStack {
TextField("guild id or slug", text: $guild.key)
.textFieldStyle(.roundedBorder)
Button("Remove") {
self.store.discordGuilds.removeAll { $0.id == guild.id }
}
.buttonStyle(.bordered)
}
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Slug")
TextField("optional slug", text: $guild.slug)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Require mention")
Toggle("", isOn: $guild.requireMention)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Reaction notifications")
Picker("", selection: $guild.reactionNotifications) {
Text("Off").tag("off")
Text("Own").tag("own")
Text("All").tag("all")
Text("Allowlist").tag("allowlist")
}
.labelsHidden()
.pickerStyle(.segmented)
}
GridRow {
self.gridLabel("Users allowlist")
TextField("123456789, username#1234", text: $guild.users)
.textFieldStyle(.roundedBorder)
}
}
Text("Channels")
.font(.caption)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 8) {
ForEach($guild.channels) { $channel in
HStack(spacing: 10) {
TextField("channel id or slug", text: $channel.key)
.textFieldStyle(.roundedBorder)
Toggle("Allow", isOn: $channel.allow)
.toggleStyle(.checkbox)
Toggle("Require mention", isOn: $channel.requireMention)
.toggleStyle(.checkbox)
Button("Remove") {
guild.channels.removeAll { $0.id == channel.id }
}
.buttonStyle(.bordered)
}
}
Button("Add channel") {
guild.channels.append(DiscordGuildChannelForm())
}
.buttonStyle(.bordered)
}
}
.padding(10)
.background(Color.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Button("Add guild") {
self.store.discordGuilds.append(DiscordGuildForm())
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GroupBox("Tool actions") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Reactions")
Toggle("", isOn: self.$store.discordActionReactions)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Stickers")
Toggle("", isOn: self.$store.discordActionStickers)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Polls")
Toggle("", isOn: self.$store.discordActionPolls)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Permissions")
Toggle("", isOn: self.$store.discordActionPermissions)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Messages")
Toggle("", isOn: self.$store.discordActionMessages)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Threads")
Toggle("", isOn: self.$store.discordActionThreads)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Pins")
Toggle("", isOn: self.$store.discordActionPins)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Search")
Toggle("", isOn: self.$store.discordActionSearch)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Member info")
Toggle("", isOn: self.$store.discordActionMemberInfo)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Role info")
Toggle("", isOn: self.$store.discordActionRoleInfo)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Channel info")
Toggle("", isOn: self.$store.discordActionChannelInfo)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Voice status")
Toggle("", isOn: self.$store.discordActionVoiceStatus)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Events")
Toggle("", isOn: self.$store.discordActionEvents)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Role changes")
Toggle("", isOn: self.$store.discordActionRoles)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Moderation")
Toggle("", isOn: self.$store.discordActionModeration)
.labelsHidden()
.toggleStyle(.checkbox)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if self.isDiscordTokenLocked {
Text("Token set via DISCORD_BOT_TOKEN env; config edits wont override it.")
.font(.caption)
.foregroundStyle(.secondary)
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveDiscordConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
}
var signalSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Connection") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$store.signalEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Account")
TextField("+15551234567", text: self.$store.signalAccount)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("HTTP URL")
TextField("http://127.0.0.1:8080", text: self.$store.signalHttpUrl)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("HTTP host")
TextField("127.0.0.1", text: self.$store.signalHttpHost)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("HTTP port")
TextField("8080", text: self.$store.signalHttpPort)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("CLI path")
TextField("signal-cli", text: self.$store.signalCliPath)
.textFieldStyle(.roundedBorder)
}
}
}
self.formSection("Behavior") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Auto start")
Toggle("", isOn: self.$store.signalAutoStart)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Receive mode")
Picker("", selection: self.$store.signalReceiveMode) {
Text("Default").tag("")
Text("on-start").tag("on-start")
Text("manual").tag("manual")
}
.labelsHidden()
.pickerStyle(.menu)
}
GridRow {
self.gridLabel("Ignore attachments")
Toggle("", isOn: self.$store.signalIgnoreAttachments)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Ignore stories")
Toggle("", isOn: self.$store.signalIgnoreStories)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Read receipts")
Toggle("", isOn: self.$store.signalSendReadReceipts)
.labelsHidden()
.toggleStyle(.checkbox)
}
}
}
self.formSection("Access & limits") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Allow from")
TextField("12345, +1555", text: self.$store.signalAllowFrom)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Media max MB")
TextField("8", text: self.$store.signalMediaMaxMb)
.textFieldStyle(.roundedBorder)
}
}
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveSignalConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
}
var imessageSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Connection") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$store.imessageEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("CLI path")
TextField("imsg", text: self.$store.imessageCliPath)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("DB path")
TextField("~/Library/Messages/chat.db", text: self.$store.imessageDbPath)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Service")
Picker("", selection: self.$store.imessageService) {
Text("auto").tag("auto")
Text("imessage").tag("imessage")
Text("sms").tag("sms")
}
.labelsHidden()
.pickerStyle(.menu)
}
}
}
self.formSection("Behavior") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Region")
TextField("US", text: self.$store.imessageRegion)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Allow from")
TextField("chat_id:101, +1555", text: self.$store.imessageAllowFrom)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Attachments")
Toggle("", isOn: self.$store.imessageIncludeAttachments)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Media max MB")
TextField("16", text: self.$store.imessageMediaMaxMb)
.textFieldStyle(.roundedBorder)
}
}
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveIMessageConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
}
@ViewBuilder
var configStatusMessage: some View {
if let status = self.store.configStatus {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
func gridLabel(_ text: String) -> some View {
Text(text)
.font(.callout.weight(.semibold))
.frame(width: 140, alignment: .leading)
}
}

View File

@ -1,63 +0,0 @@
import AppKit
import SwiftUI
struct ConnectionsSettings: View {
enum ConnectionChannel: String, CaseIterable, Identifiable, Hashable {
case whatsapp
case telegram
case discord
case signal
case imessage
var id: String { self.rawValue }
var sortOrder: Int {
switch self {
case .whatsapp: 0
case .telegram: 1
case .discord: 2
case .signal: 3
case .imessage: 4
}
}
var title: String {
switch self {
case .whatsapp: "WhatsApp"
case .telegram: "Telegram"
case .discord: "Discord"
case .signal: "Signal"
case .imessage: "iMessage"
}
}
var detailTitle: String {
switch self {
case .whatsapp: "WhatsApp Web"
case .telegram: "Telegram Bot"
case .discord: "Discord Bot"
case .signal: "Signal REST"
case .imessage: "iMessage (imsg)"
}
}
var systemImage: String {
switch self {
case .whatsapp: "message"
case .telegram: "paperplane"
case .discord: "bubble.left.and.bubble.right"
case .signal: "antenna.radiowaves.left.and.right"
case .imessage: "message.fill"
}
}
}
@Bindable var store: ConnectionsStore
@State var selectedChannel: ConnectionChannel?
@State var showTelegramToken = false
@State var showDiscordToken = false
init(store: ConnectionsStore = .shared) {
self.store = store
}
}

View File

@ -1,594 +0,0 @@
import ClawdbotProtocol
import Foundation
extension ConnectionsStore {
var isTelegramTokenLocked: Bool {
self.snapshot?.decodeChannel("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.tokenSource == "env"
}
var isDiscordTokenLocked: Bool {
self.snapshot?.decodeChannel("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.tokenSource == "env"
}
func loadConfig() async {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
params: nil,
timeoutMs: 10000)
self.configStatus = snap.valid == false
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
: nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configHash = snap.hash
self.configLoaded = true
self.applyUIConfig(snap)
self.applyTelegramConfig(snap)
self.applyDiscordConfig(snap)
self.applySignalConfig(snap)
self.applyIMessageConfig(snap)
} catch {
self.configStatus = error.localizedDescription
}
}
private func applyUIConfig(_ snap: ConfigSnapshot) {
let ui = snap.config?[
"ui",
]?.dictionaryValue
let rawSeam = ui?[
"seamColor",
]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
}
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
if let channels = snap.config?["channels"]?.dictionaryValue,
let entry = channels[key]?.dictionaryValue
{
return entry
}
return snap.config?[key]?.dictionaryValue
}
private func applyTelegramConfig(_ snap: ConfigSnapshot) {
let telegram = self.resolveChannelConfig(snap, key: "telegram")
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
let groups = telegram?["groups"]?.dictionaryValue
let defaultGroup = groups?["*"]?.dictionaryValue
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
?? telegram?["requireMention"]?.boolValue
?? true
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? ""
self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? ""
}
private func applyDiscordConfig(_ snap: ConfigSnapshot) {
let discord = self.resolveChannelConfig(snap, key: "discord")
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
self.discordToken = discord?["token"]?.stringValue ?? ""
let discordDm = discord?["dm"]?.dictionaryValue
self.discordDmEnabled = discordDm?["enabled"]?.boolValue ?? true
self.discordAllowFrom = self.stringList(from: discordDm?["allowFrom"]?.arrayValue)
self.discordGroupEnabled = discordDm?["groupEnabled"]?.boolValue ?? false
self.discordGroupChannels = self.stringList(from: discordDm?["groupChannels"]?.arrayValue)
self.discordMediaMaxMb = self.numberString(from: discord?["mediaMaxMb"])
self.discordHistoryLimit = self.numberString(from: discord?["historyLimit"])
self.discordTextChunkLimit = self.numberString(from: discord?["textChunkLimit"])
self.discordReplyToMode = self.replyMode(from: discord?["replyToMode"]?.stringValue)
self.discordGuilds = self.decodeDiscordGuilds(discord?["guilds"]?.dictionaryValue)
let discordActions = discord?["actions"]?.dictionaryValue
self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true
self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true
self.discordActionPolls = discordActions?["polls"]?.boolValue ?? true
self.discordActionPermissions = discordActions?["permissions"]?.boolValue ?? true
self.discordActionMessages = discordActions?["messages"]?.boolValue ?? true
self.discordActionThreads = discordActions?["threads"]?.boolValue ?? true
self.discordActionPins = discordActions?["pins"]?.boolValue ?? true
self.discordActionSearch = discordActions?["search"]?.boolValue ?? true
self.discordActionMemberInfo = discordActions?["memberInfo"]?.boolValue ?? true
self.discordActionRoleInfo = discordActions?["roleInfo"]?.boolValue ?? true
self.discordActionChannelInfo = discordActions?["channelInfo"]?.boolValue ?? true
self.discordActionVoiceStatus = discordActions?["voiceStatus"]?.boolValue ?? true
self.discordActionEvents = discordActions?["events"]?.boolValue ?? true
self.discordActionRoles = discordActions?["roles"]?.boolValue ?? false
self.discordActionModeration = discordActions?["moderation"]?.boolValue ?? false
let slash = discord?["slashCommand"]?.dictionaryValue
self.discordSlashEnabled = slash?["enabled"]?.boolValue ?? false
self.discordSlashName = slash?["name"]?.stringValue ?? ""
self.discordSlashSessionPrefix = slash?["sessionPrefix"]?.stringValue ?? ""
self.discordSlashEphemeral = slash?["ephemeral"]?.boolValue ?? true
}
private func decodeDiscordGuilds(_ guilds: [String: AnyCodable]?) -> [DiscordGuildForm] {
guard let guilds else { return [] }
return guilds
.map { key, value in
let entry = value.dictionaryValue ?? [:]
let slug = entry["slug"]?.stringValue ?? ""
let requireMention = entry["requireMention"]?.boolValue ?? false
let reactionModeRaw = entry["reactionNotifications"]?.stringValue ?? ""
let reactionNotifications = ["off", "own", "all", "allowlist"].contains(reactionModeRaw)
? reactionModeRaw
: "own"
let users = self.stringList(from: entry["users"]?.arrayValue)
let channels: [DiscordGuildChannelForm] = if let channelMap = entry["channels"]?.dictionaryValue {
channelMap.map { channelKey, channelValue in
let channelEntry = channelValue.dictionaryValue ?? [:]
let allow = channelEntry["allow"]?.boolValue ?? true
let channelRequireMention = channelEntry["requireMention"]?.boolValue ?? false
return DiscordGuildChannelForm(
key: channelKey,
allow: allow,
requireMention: channelRequireMention)
}
} else {
[]
}
return DiscordGuildForm(
key: key,
slug: slug,
requireMention: requireMention,
reactionNotifications: reactionNotifications,
users: users,
channels: channels)
}
.sorted { $0.key < $1.key }
}
private func applySignalConfig(_ snap: ConfigSnapshot) {
let signal = self.resolveChannelConfig(snap, key: "signal")
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
self.signalAccount = signal?["account"]?.stringValue ?? ""
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
self.signalHttpHost = signal?["httpHost"]?.stringValue ?? ""
self.signalHttpPort = self.numberString(from: signal?["httpPort"])
self.signalCliPath = signal?["cliPath"]?.stringValue ?? ""
self.signalAutoStart = signal?["autoStart"]?.boolValue ?? true
self.signalReceiveMode = signal?["receiveMode"]?.stringValue ?? ""
self.signalIgnoreAttachments = signal?["ignoreAttachments"]?.boolValue ?? false
self.signalIgnoreStories = signal?["ignoreStories"]?.boolValue ?? false
self.signalSendReadReceipts = signal?["sendReadReceipts"]?.boolValue ?? false
self.signalAllowFrom = self.stringList(from: signal?["allowFrom"]?.arrayValue)
self.signalMediaMaxMb = self.numberString(from: signal?["mediaMaxMb"])
}
private func applyIMessageConfig(_ snap: ConfigSnapshot) {
let imessage = self.resolveChannelConfig(snap, key: "imessage")
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
self.imessageService = imessage?["service"]?.stringValue ?? "auto"
self.imessageRegion = imessage?["region"]?.stringValue ?? ""
self.imessageAllowFrom = self.stringList(from: imessage?["allowFrom"]?.arrayValue)
self.imessageIncludeAttachments = imessage?["includeAttachments"]?.boolValue ?? false
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
}
private func channelConfigRoot(for key: String) -> [String: Any] {
if let channels = self.configRoot["channels"] as? [String: Any],
let entry = channels[key] as? [String: Any]
{
return entry
}
return self.configRoot[key] as? [String: Any] ?? [:]
}
func saveTelegramConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
var telegram: [String: Any] = [:]
if !self.isTelegramTokenLocked {
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
}
telegram["requireMention"] = NSNull()
telegram["groups"] = [
"*": [
"requireMention": self.telegramRequireMention,
],
]
let allow = self.splitCsv(self.telegramAllowFrom)
self.setPatchList(&telegram, key: "allowFrom", values: allow)
self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
await self.persistChannelPatch("telegram", payload: telegram)
}
func saveDiscordConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
let base = self.channelConfigRoot(for: "discord")
let discord = self.buildDiscordPatch(base: base)
await self.persistChannelPatch("discord", payload: discord)
}
func saveSignalConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
var signal: [String: Any] = [:]
self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
self.setPatchString(&signal, key: "account", value: self.signalAccount)
self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost)
self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort)
self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath)
self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
let allow = self.splitCsv(self.signalAllowFrom)
self.setPatchList(&signal, key: "allowFrom", values: allow)
self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
await self.persistChannelPatch("signal", payload: signal)
}
func saveIMessageConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
var imessage: [String: Any] = [:]
self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
let service = self.trimmed(self.imessageService)
if service.isEmpty || service == "auto" {
imessage["service"] = NSNull()
} else {
imessage["service"] = service
}
self.setPatchString(&imessage, key: "region", value: self.imessageRegion)
let allow = self.splitCsv(self.imessageAllowFrom)
self.setPatchList(&imessage, key: "allowFrom", values: allow)
self.setPatchBool(
&imessage,
key: "includeAttachments",
value: self.imessageIncludeAttachments,
defaultValue: false)
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
await self.persistChannelPatch("imessage", payload: imessage)
}
private func buildDiscordPatch(base: [String: Any]) -> [String: Any] {
var discord: [String: Any] = [:]
self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
if !self.isDiscordTokenLocked {
self.setPatchString(&discord, key: "token", value: self.discordToken)
}
if let dm = self.buildDiscordDmPatch() {
discord["dm"] = dm
} else {
discord["dm"] = NSNull()
}
self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
let replyToMode = self.trimmed(self.discordReplyToMode)
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
discord["replyToMode"] = NSNull()
} else {
discord["replyToMode"] = replyToMode
}
let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
discord["guilds"] = guilds
} else {
discord["guilds"] = NSNull()
}
if let actions = self.buildDiscordActionsPatch() {
discord["actions"] = actions
} else {
discord["actions"] = NSNull()
}
if let slash = self.buildDiscordSlashPatch() {
discord["slashCommand"] = slash
} else {
discord["slashCommand"] = NSNull()
}
return discord
}
private func buildDiscordDmPatch() -> [String: Any]? {
var dm: [String: Any] = [:]
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
let allow = self.splitCsv(self.discordAllowFrom)
self.setPatchList(&dm, key: "allowFrom", values: allow)
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
let groupChannels = self.splitCsv(self.discordGroupChannels)
self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
return dm.isEmpty ? nil : dm
}
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
if self.discordGuilds.isEmpty {
return NSNull()
}
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for entry in self.discordGuilds {
let key = self.trimmed(entry.key)
guard !key.isEmpty else { continue }
formKeys.insert(key)
let baseGuild = base[key] as? [String: Any] ?? [:]
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild)
}
for key in baseKeys.subtracting(formKeys) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
}
private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
var payload: [String: Any] = [:]
let slug = self.trimmed(entry.slug)
if slug.isEmpty {
payload["slug"] = NSNull()
} else {
payload["slug"] = slug
}
if entry.requireMention {
payload["requireMention"] = true
} else {
payload["requireMention"] = NSNull()
}
if ["off", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
} else {
payload["reactionNotifications"] = NSNull()
}
let users = self.splitCsv(entry.users)
self.setPatchList(&payload, key: "users", values: users)
let baseChannels = base["channels"] as? [String: Any] ?? [:]
if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) {
payload["channels"] = channels
} else {
payload["channels"] = NSNull()
}
return payload
}
private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? {
if forms.isEmpty {
return NSNull()
}
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for channel in forms {
let channelKey = self.trimmed(channel.key)
guard !channelKey.isEmpty else { continue }
formKeys.insert(channelKey)
var channelPayload: [String: Any] = [:]
self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true)
self.setPatchBool(
&channelPayload,
key: "requireMention",
value: channel.requireMention,
defaultValue: false)
patch[channelKey] = channelPayload
}
for key in baseKeys.subtracting(formKeys) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
}
private func buildDiscordActionsPatch() -> [String: Any]? {
var actions: [String: Any] = [:]
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
self.setAction(&actions, key: "permissions", value: self.discordActionPermissions, defaultValue: true)
self.setAction(&actions, key: "messages", value: self.discordActionMessages, defaultValue: true)
self.setAction(&actions, key: "threads", value: self.discordActionThreads, defaultValue: true)
self.setAction(&actions, key: "pins", value: self.discordActionPins, defaultValue: true)
self.setAction(&actions, key: "search", value: self.discordActionSearch, defaultValue: true)
self.setAction(&actions, key: "memberInfo", value: self.discordActionMemberInfo, defaultValue: true)
self.setAction(&actions, key: "roleInfo", value: self.discordActionRoleInfo, defaultValue: true)
self.setAction(&actions, key: "channelInfo", value: self.discordActionChannelInfo, defaultValue: true)
self.setAction(&actions, key: "voiceStatus", value: self.discordActionVoiceStatus, defaultValue: true)
self.setAction(&actions, key: "events", value: self.discordActionEvents, defaultValue: true)
self.setAction(&actions, key: "roles", value: self.discordActionRoles, defaultValue: false)
self.setAction(&actions, key: "moderation", value: self.discordActionModeration, defaultValue: false)
return actions.isEmpty ? nil : actions
}
private func buildDiscordSlashPatch() -> [String: Any]? {
var slash: [String: Any] = [:]
self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
self.setPatchString(&slash, key: "name", value: self.discordSlashName)
self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
return slash.isEmpty ? nil : slash
}
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
do {
guard let baseHash = self.configHash else {
self.configStatus = "Config hash missing; reload and retry."
return
}
let data = try JSONSerialization.data(
withJSONObject: ["channels": [channelId: payload]],
options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
self.configStatus = "Failed to encode config."
return
}
let params: [String: AnyCodable] = [
"raw": AnyCodable(raw),
"baseHash": AnyCodable(baseHash),
]
_ = try await GatewayConnection.shared.requestRaw(
method: .configPatch,
params: params,
timeoutMs: 10000)
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
await self.loadConfig()
await self.refresh(probe: true)
} catch {
self.configStatus = error.localizedDescription
}
}
private func stringList(from values: [AnyCodable]?) -> String {
guard let values else { return "" }
let strings = values.compactMap { entry -> String? in
if let str = entry.stringValue { return str }
if let intVal = entry.intValue { return String(intVal) }
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
return nil
}
return strings.joined(separator: ", ")
}
private func numberString(from value: AnyCodable?) -> String {
if let number = value?.doubleValue ?? value?.intValue.map(Double.init) {
return String(Int(number))
}
return ""
}
private func replyMode(from value: String?) -> String {
if let value, ["off", "first", "all"].contains(value) {
return value
}
return "off"
}
private func splitCsv(_ value: String) -> [String] {
value
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
private func trimmed(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func setPatchString(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target[key] = NSNull()
} else {
target[key] = trimmed
}
}
private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target[key] = NSNull()
return
}
if let number = Double(trimmed) {
target[key] = number
} else {
target[key] = NSNull()
}
}
private func setPatchInt(
_ target: inout [String: Any],
key: String,
value: String,
allowZero: Bool)
{
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target[key] = NSNull()
return
}
guard let number = Int(trimmed) else {
target[key] = NSNull()
return
}
let isValid = allowZero ? number >= 0 : number > 0
guard isValid else {
target[key] = NSNull()
return
}
target[key] = number
}
private func setPatchBool(
_ target: inout [String: Any],
key: String,
value: Bool,
defaultValue: Bool)
{
if value == defaultValue {
target[key] = NSNull()
} else {
target[key] = value
}
}
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
if values.isEmpty {
target[key] = NSNull()
} else {
target[key] = values
}
}
private func setAction(
_ actions: inout [String: Any],
key: String,
value: Bool,
defaultValue: Bool)
{
if value == defaultValue {
actions[key] = NSNull()
} else {
actions[key] = value
}
}
}

View File

@ -900,7 +900,7 @@ extension DebugSettings {
} }
} }
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle { struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
configuration.label configuration.label

View File

@ -56,6 +56,7 @@ actor GatewayConnection {
case configGet = "config.get" case configGet = "config.get"
case configSet = "config.set" case configSet = "config.set"
case configPatch = "config.patch" case configPatch = "config.patch"
case configSchema = "config.schema"
case wizardStart = "wizard.start" case wizardStart = "wizard.start"
case wizardNext = "wizard.next" case wizardNext = "wizard.next"
case wizardCancel = "wizard.cancel" case wizardCancel = "wizard.cancel"

View File

@ -694,10 +694,10 @@ extension OnboardingView {
systemImage: "bubble.left.and.bubble.right") systemImage: "bubble.left.and.bubble.right")
self.featureActionRow( self.featureActionRow(
title: "Connect WhatsApp or Telegram", title: "Connect WhatsApp or Telegram",
subtitle: "Open Settings → Connections to link channels and monitor status.", subtitle: "Open Settings → Channels to link channels and monitor status.",
systemImage: "link") systemImage: "link")
{ {
self.openSettings(tab: .connections) self.openSettings(tab: .channels)
} }
self.featureRow( self.featureRow(
title: "Try Voice Wake", title: "Try Voice Wake",

View File

@ -27,9 +27,9 @@ struct SettingsRootView: View {
.tabItem { Label("General", systemImage: "gearshape") } .tabItem { Label("General", systemImage: "gearshape") }
.tag(SettingsTab.general) .tag(SettingsTab.general)
ConnectionsSettings() ChannelsSettings()
.tabItem { Label("Connections", systemImage: "link") } .tabItem { Label("Channels", systemImage: "link") }
.tag(SettingsTab.connections) .tag(SettingsTab.channels)
VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake) VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake)
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") } .tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
@ -176,13 +176,13 @@ struct SettingsRootView: View {
} }
enum SettingsTab: CaseIterable { enum SettingsTab: CaseIterable {
case general, connections, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about case general, channels, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
static let windowWidth: CGFloat = 824 // wider static let windowWidth: CGFloat = 824 // wider
static let windowHeight: CGFloat = 790 // +10% (more room) static let windowHeight: CGFloat = 790 // +10% (more room)
var title: String { var title: String {
switch self { switch self {
case .general: "General" case .general: "General"
case .connections: "Connections" case .channels: "Channels"
case .skills: "Skills" case .skills: "Skills"
case .sessions: "Sessions" case .sessions: "Sessions"
case .cron: "Cron" case .cron: "Cron"
@ -198,7 +198,7 @@ enum SettingsTab: CaseIterable {
var systemImage: String { var systemImage: String {
switch self { switch self {
case .general: "gearshape" case .general: "gearshape"
case .connections: "link" case .channels: "link"
case .skills: "sparkles" case .skills: "sparkles"
case .sessions: "clock.arrow.circlepath" case .sessions: "clock.arrow.circlepath"
case .cron: "calendar" case .cron: "calendar"

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

@ -22,6 +22,9 @@ If the file is missing, Clawdbot uses safe-ish defaults (embedded Pi agent + per
The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors. The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors.
The Control UI renders a form from this schema, with a **Raw JSON** editor as an escape hatch. The Control UI renders a form from this schema, with a **Raw JSON** editor as an escape hatch.
Channel plugins and extensions can register schema + UI hints for their config, so channel settings
stay schema-driven across apps without hard-coded forms.
Hints (labels, grouping, sensitive fields) ship alongside the schema so clients can render Hints (labels, grouping, sensitive fields) ship alongside the schema so clients can render
better forms without hard-coding config knowledge. better forms without hard-coding config knowledge.
@ -945,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`).
@ -1078,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

@ -30,7 +30,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
## What it can do (today) ## What it can do (today)
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`) - Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`)
- Stream tool calls + live tool output cards in Chat (agent events) - Stream tool calls + live tool output cards in Chat (agent events)
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`) - Channels: WhatsApp/Telegram status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
- Instances: presence list + refresh (`system-presence`) - Instances: presence list + refresh (`system-presence`)
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`) - Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`) - Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
@ -39,23 +39,11 @@ The onboarding wizard generates a gateway token by default, so paste it here on
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`) - Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
- Config: apply + restart with validation (`config.apply`) and wake the last active session - Config: apply + restart with validation (`config.apply`) and wake the last active session
- Config writes include a base-hash guard to prevent clobbering concurrent edits - Config writes include a base-hash guard to prevent clobbering concurrent edits
- Config schema + form rendering (`config.schema`); Raw JSON editor remains available - Config schema + form rendering (`config.schema`, including plugin + channel schemas); Raw JSON editor remains available
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`) - Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`)
- Logs: live tail of gateway file logs with filter/export (`logs.tail`) - Logs: live tail of gateway file logs with filter/export (`logs.tail`)
- Update: run a package/git update + restart (`update.run`) with a restart report - Update: run a package/git update + restart (`update.run`) with a restart report
## Model presets (Config tab)
The Config tab includes **Model presets**: one-click inserts to add common model providers and set a default model:
- **MiniMax M2.1 (Anthropic)** → configures MiniMax via `https://api.minimax.io/anthropic` and `anthropic-messages` (see [/providers/minimax](/providers/minimax))
- **GLM 4.7 (Z.AI)** → adds `ZAI_API_KEY` + sets `zai/glm-4.7` (see [/providers/zai](/providers/zai))
- **Kimi (Moonshot)** → configures Moonshot + sets `moonshot/kimi-k2-0905-preview` (see [/providers/moonshot](/providers/moonshot))
Notes:
- Presets **keep existing API keys and per-model params** when present.
- Use `/model` (see [/tools/slash-commands](/tools/slash-commands)) to switch models from chat without editing config.
## Chat behavior ## Chat behavior
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events. - `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.

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,7 +1,9 @@
import type { ClawdbotConfig } from "../../../src/config/config.js"; import type { ClawdbotConfig } from "../../../src/config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { MSTeamsConfigSchema } from "../../../src/config/zod-schema.providers-core.js";
import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js";
import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js"; import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js";
import type { ChannelMessageActionName, ChannelPlugin } from "../../../src/channels/plugins/types.js"; import type { ChannelMessageActionName, ChannelPlugin } from "../../../src/channels/plugins/types.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { msteamsOnboardingAdapter } from "./onboarding.js"; import { msteamsOnboardingAdapter } from "./onboarding.js";
import { msteamsOutbound } from "./outbound.js"; import { msteamsOutbound } from "./outbound.js";
@ -64,6 +66,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
media: true, media: true,
}, },
reload: { configPrefixes: ["channels.msteams"] }, reload: { configPrefixes: ["channels.msteams"] },
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
config: { config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID], listAccountIds: () => [DEFAULT_ACCOUNT_ID],
resolveAccount: (cfg) => ({ resolveAccount: (cfg) => ({

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

@ -43,7 +43,6 @@ type FirecrawlFetchConfig =
timeoutSeconds?: number; timeoutSeconds?: number;
} }
| undefined; | undefined;
type CacheEntry<T> = { type CacheEntry<T> = {
value: T; value: T;
expiresAt: number; expiresAt: number;

View File

@ -0,0 +1,12 @@
import type { ZodTypeAny } from "zod";
import type { ChannelConfigSchema } from "./types.plugin.js";
export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema {
return {
schema: schema.toJSONSchema({
target: "draft-07",
unrepresentable: "any",
}) as Record<string, unknown>,
};
}

View File

@ -13,7 +13,9 @@ import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js";
import { shouldLogVerbose } from "../../globals.js"; import { shouldLogVerbose } from "../../globals.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { getChatChannelMeta } from "../registry.js"; import { getChatChannelMeta } from "../registry.js";
import { DiscordConfigSchema } from "../../config/zod-schema.providers-core.js";
import { discordMessageActions } from "./actions/discord.js"; import { discordMessageActions } from "./actions/discord.js";
import { buildChannelConfigSchema } from "./config-schema.js";
import { import {
deleteAccountFromConfigSection, deleteAccountFromConfigSection,
setAccountEnabledInConfigSection, setAccountEnabledInConfigSection,
@ -57,6 +59,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
}, },
reload: { configPrefixes: ["channels.discord"] }, reload: { configPrefixes: ["channels.discord"] },
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
config: { config: {
listAccountIds: (cfg) => listDiscordAccountIds(cfg), listAccountIds: (cfg) => listDiscordAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),

View File

@ -9,6 +9,8 @@ import { probeIMessage } from "../../imessage/probe.js";
import { sendMessageIMessage } from "../../imessage/send.js"; import { sendMessageIMessage } from "../../imessage/send.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { getChatChannelMeta } from "../registry.js"; import { getChatChannelMeta } from "../registry.js";
import { IMessageConfigSchema } from "../../config/zod-schema.providers-core.js";
import { buildChannelConfigSchema } from "./config-schema.js";
import { import {
deleteAccountFromConfigSection, deleteAccountFromConfigSection,
setAccountEnabledInConfigSection, setAccountEnabledInConfigSection,
@ -44,6 +46,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
media: true, media: true,
}, },
reload: { configPrefixes: ["channels.imessage"] }, reload: { configPrefixes: ["channels.imessage"] },
configSchema: buildChannelConfigSchema(IMessageConfigSchema),
config: { config: {
listAccountIds: (cfg) => listIMessageAccountIds(cfg), listAccountIds: (cfg) => listIMessageAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }),

View File

@ -10,6 +10,8 @@ import { probeSignal } from "../../signal/probe.js";
import { sendMessageSignal } from "../../signal/send.js"; import { sendMessageSignal } from "../../signal/send.js";
import { normalizeE164 } from "../../utils.js"; import { normalizeE164 } from "../../utils.js";
import { getChatChannelMeta } from "../registry.js"; import { getChatChannelMeta } from "../registry.js";
import { SignalConfigSchema } from "../../config/zod-schema.providers-core.js";
import { buildChannelConfigSchema } from "./config-schema.js";
import { import {
deleteAccountFromConfigSection, deleteAccountFromConfigSection,
setAccountEnabledInConfigSection, setAccountEnabledInConfigSection,
@ -48,6 +50,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
}, },
reload: { configPrefixes: ["channels.signal"] }, reload: { configPrefixes: ["channels.signal"] },
configSchema: buildChannelConfigSchema(SignalConfigSchema),
config: { config: {
listAccountIds: (cfg) => listSignalAccountIds(cfg), listAccountIds: (cfg) => listSignalAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }),

View File

@ -12,6 +12,8 @@ import {
import { probeSlack } from "../../slack/probe.js"; import { probeSlack } from "../../slack/probe.js";
import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageSlack } from "../../slack/send.js";
import { getChatChannelMeta } from "../registry.js"; import { getChatChannelMeta } from "../registry.js";
import { SlackConfigSchema } from "../../config/zod-schema.providers-core.js";
import { buildChannelConfigSchema } from "./config-schema.js";
import { import {
deleteAccountFromConfigSection, deleteAccountFromConfigSection,
setAccountEnabledInConfigSection, setAccountEnabledInConfigSection,
@ -80,6 +82,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
}, },
reload: { configPrefixes: ["channels.slack"] }, reload: { configPrefixes: ["channels.slack"] },
configSchema: buildChannelConfigSchema(SlackConfigSchema),
config: { config: {
listAccountIds: (cfg) => listSlackAccountIds(cfg), listAccountIds: (cfg) => listSlackAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),

View File

@ -17,7 +17,9 @@ import { probeTelegram } from "../../telegram/probe.js";
import { sendMessageTelegram } from "../../telegram/send.js"; import { sendMessageTelegram } from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js"; import { resolveTelegramToken } from "../../telegram/token.js";
import { getChatChannelMeta } from "../registry.js"; import { getChatChannelMeta } from "../registry.js";
import { TelegramConfigSchema } from "../../config/zod-schema.providers-core.js";
import { telegramMessageActions } from "./actions/telegram.js"; import { telegramMessageActions } from "./actions/telegram.js";
import { buildChannelConfigSchema } from "./config-schema.js";
import { import {
deleteAccountFromConfigSection, deleteAccountFromConfigSection,
setAccountEnabledInConfigSection, setAccountEnabledInConfigSection,
@ -77,6 +79,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
blockStreaming: true, blockStreaming: true,
}, },
reload: { configPrefixes: ["channels.telegram"] }, reload: { configPrefixes: ["channels.telegram"] },
configSchema: buildChannelConfigSchema(TelegramConfigSchema),
config: { config: {
listAccountIds: (cfg) => listTelegramAccountIds(cfg), listAccountIds: (cfg) => listTelegramAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }),

View File

@ -29,6 +29,20 @@ import type {
// Channel docking: implement this contract in src/channels/plugins/<id>.ts. // Channel docking: implement this contract in src/channels/plugins/<id>.ts.
// biome-ignore lint/suspicious/noExplicitAny: registry aggregates heterogeneous account types. // biome-ignore lint/suspicious/noExplicitAny: registry aggregates heterogeneous account types.
export type ChannelConfigUiHint = {
label?: string;
help?: string;
advanced?: boolean;
sensitive?: boolean;
placeholder?: string;
itemTemplate?: unknown;
};
export type ChannelConfigSchema = {
schema: Record<string, unknown>;
uiHints?: Record<string, ChannelConfigUiHint>;
};
export type ChannelPlugin<ResolvedAccount = any> = { export type ChannelPlugin<ResolvedAccount = any> = {
id: ChannelId; id: ChannelId;
meta: ChannelMeta; meta: ChannelMeta;
@ -37,6 +51,7 @@ export type ChannelPlugin<ResolvedAccount = any> = {
// CLI onboarding wizard hooks for this channel. // CLI onboarding wizard hooks for this channel.
onboarding?: ChannelOnboardingAdapter; onboarding?: ChannelOnboardingAdapter;
config: ChannelConfigAdapter<ResolvedAccount>; config: ChannelConfigAdapter<ResolvedAccount>;
configSchema?: ChannelConfigSchema;
setup?: ChannelSetupAdapter; setup?: ChannelSetupAdapter;
pairing?: ChannelPairingAdapter; pairing?: ChannelPairingAdapter;
security?: ChannelSecurityAdapter<ResolvedAccount>; security?: ChannelSecurityAdapter<ResolvedAccount>;

View File

@ -21,6 +21,8 @@ import {
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
import { getChatChannelMeta } from "../registry.js"; import { getChatChannelMeta } from "../registry.js";
import { WhatsAppConfigSchema } from "../../config/zod-schema.providers-whatsapp.js";
import { buildChannelConfigSchema } from "./config-schema.js";
import { createWhatsAppLoginTool } from "./agent-tools/whatsapp-login.js"; import { createWhatsAppLoginTool } from "./agent-tools/whatsapp-login.js";
import { resolveWhatsAppGroupRequireMention } from "./group-mentions.js"; import { resolveWhatsAppGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js"; import { formatPairingApproveHint } from "./helpers.js";
@ -60,6 +62,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
}, },
reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] },
gatewayMethods: ["web.login.start", "web.login.wait"], gatewayMethods: ["web.login.start", "web.login.wait"],
configSchema: buildChannelConfigSchema(WhatsAppConfigSchema),
config: { config: {
listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), listAccountIds: (cfg) => listWhatsAppAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }),

View File

@ -36,4 +36,52 @@ describe("config schema", () => {
); );
expect(res.uiHints["plugins.entries.voice-call.config.twilio.authToken"]?.sensitive).toBe(true); expect(res.uiHints["plugins.entries.voice-call.config.twilio.authToken"]?.sensitive).toBe(true);
}); });
it("merges plugin + channel schemas", () => {
const res = buildConfigSchema({
plugins: [
{
id: "voice-call",
name: "Voice Call",
configSchema: {
type: "object",
properties: {
provider: { type: "string" },
},
},
},
],
channels: [
{
id: "matrix",
label: "Matrix",
configSchema: {
type: "object",
properties: {
accessToken: { type: "string" },
},
},
},
],
});
const schema = res.schema as {
properties?: Record<string, unknown>;
};
const pluginsNode = schema.properties?.plugins as Record<string, unknown> | undefined;
const entriesNode = pluginsNode?.properties as Record<string, unknown> | undefined;
const entriesProps = entriesNode?.entries as Record<string, unknown> | undefined;
const entryProps = entriesProps?.properties as Record<string, unknown> | undefined;
const pluginEntry = entryProps?.["voice-call"] as Record<string, unknown> | undefined;
const pluginConfig = pluginEntry?.properties as Record<string, unknown> | undefined;
const pluginConfigSchema = pluginConfig?.config as Record<string, unknown> | undefined;
const pluginConfigProps = pluginConfigSchema?.properties as Record<string, unknown> | undefined;
expect(pluginConfigProps?.provider).toBeTruthy();
const channelsNode = schema.properties?.channels as Record<string, unknown> | undefined;
const channelsProps = channelsNode?.properties as Record<string, unknown> | undefined;
const channelSchema = channelsProps?.matrix as Record<string, unknown> | undefined;
const channelProps = channelSchema?.properties as Record<string, unknown> | undefined;
expect(channelProps?.accessToken).toBeTruthy();
});
}); });

View File

@ -16,6 +16,8 @@ export type ConfigUiHints = Record<string, ConfigUiHint>;
export type ConfigSchema = ReturnType<typeof ClawdbotSchema.toJSONSchema>; export type ConfigSchema = ReturnType<typeof ClawdbotSchema.toJSONSchema>;
type JsonSchemaNode = Record<string, unknown>;
export type ConfigSchemaResponse = { export type ConfigSchemaResponse = {
schema: ConfigSchema; schema: ConfigSchema;
uiHints: ConfigUiHints; uiHints: ConfigUiHints;
@ -31,12 +33,15 @@ export type PluginUiMetadata = {
string, string,
Pick<ConfigUiHint, "label" | "help" | "advanced" | "sensitive" | "placeholder"> Pick<ConfigUiHint, "label" | "help" | "advanced" | "sensitive" | "placeholder">
>; >;
configSchema?: JsonSchemaNode;
}; };
export type ChannelUiMetadata = { export type ChannelUiMetadata = {
id: string; id: string;
label?: string; label?: string;
description?: string; description?: string;
configSchema?: JsonSchemaNode;
configUiHints?: Record<string, ConfigUiHint>;
}; };
const GROUP_LABELS: Record<string, string> = { const GROUP_LABELS: Record<string, string> = {
@ -433,6 +438,51 @@ function isSensitivePath(path: string): boolean {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path)); return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path));
} }
type JsonSchemaObject = JsonSchemaNode & {
type?: string | string[];
properties?: Record<string, JsonSchemaObject>;
required?: string[];
additionalProperties?: JsonSchemaObject | boolean;
};
function cloneSchema<T>(value: T): T {
if (typeof structuredClone === "function") return structuredClone(value);
return JSON.parse(JSON.stringify(value)) as T;
}
function asSchemaObject(value: unknown): JsonSchemaObject | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as JsonSchemaObject;
}
function isObjectSchema(schema: JsonSchemaObject): boolean {
const type = schema.type;
if (type === "object") return true;
if (Array.isArray(type) && type.includes("object")) return true;
return Boolean(schema.properties || schema.additionalProperties);
}
function mergeObjectSchema(base: JsonSchemaObject, extension: JsonSchemaObject): JsonSchemaObject {
const mergedRequired = new Set<string>([
...(base.required ?? []),
...(extension.required ?? []),
]);
const merged: JsonSchemaObject = {
...base,
...extension,
properties: {
...base.properties,
...extension.properties,
},
};
if (mergedRequired.size > 0) {
merged.required = Array.from(mergedRequired);
}
const additional = extension.additionalProperties ?? base.additionalProperties;
if (additional !== undefined) merged.additionalProperties = additional;
return merged;
}
function buildBaseHints(): ConfigUiHints { function buildBaseHints(): ConfigUiHints {
const hints: ConfigUiHints = {}; const hints: ConfigUiHints = {};
for (const [group, label] of Object.entries(GROUP_LABELS)) { for (const [group, label] of Object.entries(GROUP_LABELS)) {
@ -520,12 +570,90 @@ function applyChannelHints(hints: ConfigUiHints, channels: ChannelUiMetadata[]):
...(label ? { label } : {}), ...(label ? { label } : {}),
...(help ? { help } : {}), ...(help ? { help } : {}),
}; };
const uiHints = channel.configUiHints ?? {};
for (const [relPathRaw, hint] of Object.entries(uiHints)) {
const relPath = relPathRaw.trim().replace(/^\./, "");
if (!relPath) continue;
const key = `${basePath}.${relPath}`;
next[key] = {
...next[key],
...hint,
};
}
} }
return next; return next;
} }
function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
const pluginsNode = asSchemaObject(root?.properties?.plugins);
const entriesNode = asSchemaObject(pluginsNode?.properties?.entries);
if (!entriesNode) return next;
const entryBase = asSchemaObject(entriesNode.additionalProperties);
const entryProperties = entriesNode.properties ?? {};
entriesNode.properties = entryProperties;
for (const plugin of plugins) {
if (!plugin.configSchema) continue;
const entrySchema = entryBase ? cloneSchema(entryBase) : ({ type: "object" } as JsonSchemaObject);
const entryObject = asSchemaObject(entrySchema) ?? ({ type: "object" } as JsonSchemaObject);
const baseConfigSchema = asSchemaObject(entryObject.properties?.config);
const pluginSchema = asSchemaObject(plugin.configSchema);
const nextConfigSchema =
baseConfigSchema && pluginSchema && isObjectSchema(baseConfigSchema) && isObjectSchema(pluginSchema)
? mergeObjectSchema(baseConfigSchema, pluginSchema)
: cloneSchema(plugin.configSchema);
entryObject.properties = {
...entryObject.properties,
config: nextConfigSchema,
};
entryProperties[plugin.id] = entryObject;
}
return next;
}
function applyChannelSchemas(schema: ConfigSchema, channels: ChannelUiMetadata[]): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
const channelsNode = asSchemaObject(root?.properties?.channels);
if (!channelsNode) return next;
const channelProps = channelsNode.properties ?? {};
channelsNode.properties = channelProps;
for (const channel of channels) {
if (!channel.configSchema) continue;
const existing = asSchemaObject(channelProps[channel.id]);
const incoming = asSchemaObject(channel.configSchema);
if (existing && incoming && isObjectSchema(existing) && isObjectSchema(incoming)) {
channelProps[channel.id] = mergeObjectSchema(existing, incoming);
} else {
channelProps[channel.id] = cloneSchema(channel.configSchema);
}
}
return next;
}
let cachedBase: ConfigSchemaResponse | null = null; let cachedBase: ConfigSchemaResponse | null = null;
function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
if (!root || !root.properties) return next;
const channelsNode = asSchemaObject(root.properties.channels);
if (channelsNode) {
channelsNode.properties = {};
channelsNode.required = [];
channelsNode.additionalProperties = true;
}
return next;
}
function buildBaseConfigSchema(): ConfigSchemaResponse { function buildBaseConfigSchema(): ConfigSchemaResponse {
if (cachedBase) return cachedBase; if (cachedBase) return cachedBase;
const schema = ClawdbotSchema.toJSONSchema({ const schema = ClawdbotSchema.toJSONSchema({
@ -535,7 +663,7 @@ function buildBaseConfigSchema(): ConfigSchemaResponse {
schema.title = "ClawdbotConfig"; schema.title = "ClawdbotConfig";
const hints = applySensitiveHints(buildBaseHints()); const hints = applySensitiveHints(buildBaseHints());
const next = { const next = {
schema, schema: stripChannelSchema(schema),
uiHints: hints, uiHints: hints,
version: VERSION, version: VERSION,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
@ -552,11 +680,16 @@ export function buildConfigSchema(params?: {
const plugins = params?.plugins ?? []; const plugins = params?.plugins ?? [];
const channels = params?.channels ?? []; const channels = params?.channels ?? [];
if (plugins.length === 0 && channels.length === 0) return base; if (plugins.length === 0 && channels.length === 0) return base;
const merged = applySensitiveHints( const mergedHints = applySensitiveHints(
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels), applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
); );
const mergedSchema = applyChannelSchemas(
applyPluginSchemas(base.schema, plugins),
channels,
);
return { return {
...base, ...base,
uiHints: merged, schema: mergedSchema,
uiHints: mergedHints,
}; };
} }

View File

@ -11,6 +11,7 @@ import {
import { applyLegacyMigrations } from "../config/legacy.js"; import { applyLegacyMigrations } from "../config/legacy.js";
import { applyMergePatch } from "../config/merge-patch.js"; import { applyMergePatch } from "../config/merge-patch.js";
import { buildConfigSchema } from "../config/schema.js"; import { buildConfigSchema } from "../config/schema.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { loadClawdbotPlugins } from "../plugins/loader.js"; import { loadClawdbotPlugins } from "../plugins/loader.js";
import { import {
ErrorCodes, ErrorCodes,
@ -114,11 +115,14 @@ export const handleConfigBridgeMethods: BridgeMethodHandler = async (
name: plugin.name, name: plugin.name,
description: plugin.description, description: plugin.description,
configUiHints: plugin.configUiHints, configUiHints: plugin.configUiHints,
configSchema: plugin.configJsonSchema,
})), })),
channels: pluginRegistry.channels.map((entry) => ({ channels: listChannelPlugins().map((entry) => ({
id: entry.plugin.id, id: entry.id,
label: entry.plugin.meta.label, label: entry.meta.label,
description: entry.plugin.meta.blurb, description: entry.meta.blurb,
configSchema: entry.configSchema?.schema,
configUiHints: entry.configSchema?.uiHints,
})), })),
}); });
return { ok: true, payloadJSON: JSON.stringify(schema) }; return { ok: true, payloadJSON: JSON.stringify(schema) };

View File

@ -17,6 +17,7 @@ import {
type RestartSentinelPayload, type RestartSentinelPayload,
writeRestartSentinel, writeRestartSentinel,
} from "../../infra/restart-sentinel.js"; } from "../../infra/restart-sentinel.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { loadClawdbotPlugins } from "../../plugins/loader.js"; import { loadClawdbotPlugins } from "../../plugins/loader.js";
import { import {
ErrorCodes, ErrorCodes,
@ -127,11 +128,14 @@ export const configHandlers: GatewayRequestHandlers = {
name: plugin.name, name: plugin.name,
description: plugin.description, description: plugin.description,
configUiHints: plugin.configUiHints, configUiHints: plugin.configUiHints,
configSchema: plugin.configJsonSchema,
})), })),
channels: pluginRegistry.channels.map((entry) => ({ channels: listChannelPlugins().map((entry) => ({
id: entry.plugin.id, id: entry.id,
label: entry.plugin.meta.label, label: entry.meta.label,
description: entry.plugin.meta.blurb, description: entry.meta.blurb,
configSchema: entry.configSchema?.schema,
configUiHints: entry.configSchema?.uiHints,
})), })),
}); });
respond(true, schema, undefined); respond(true, schema, undefined);

View File

@ -197,6 +197,7 @@ function createPluginRecord(params: {
httpHandlers: 0, httpHandlers: 0,
configSchema: params.configSchema, configSchema: params.configSchema,
configUiHints: undefined, configUiHints: undefined,
configJsonSchema: undefined,
}; };
} }
@ -302,6 +303,17 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
PluginConfigUiHint PluginConfigUiHint
>) >)
: undefined; : undefined;
record.configJsonSchema =
definition?.configSchema &&
typeof definition.configSchema === "object" &&
(definition.configSchema as { jsonSchema?: unknown }).jsonSchema &&
typeof (definition.configSchema as { jsonSchema?: unknown }).jsonSchema === "object" &&
!Array.isArray((definition.configSchema as { jsonSchema?: unknown }).jsonSchema)
? ((definition.configSchema as { jsonSchema?: unknown }).jsonSchema as Record<
string,
unknown
>)
: undefined;
const validatedConfig = validatePluginConfig({ const validatedConfig = validatePluginConfig({
schema: definition?.configSchema, schema: definition?.configSchema,

View File

@ -80,6 +80,7 @@ export type PluginRecord = {
httpHandlers: number; httpHandlers: number;
configSchema: boolean; configSchema: boolean;
configUiHints?: Record<string, PluginConfigUiHint>; configUiHints?: Record<string, PluginConfigUiHint>;
configJsonSchema?: Record<string, unknown>;
}; };
export type PluginRegistry = { export type PluginRegistry = {

View File

@ -42,6 +42,7 @@ export type ClawdbotPluginConfigSchema = {
parse?: (value: unknown) => unknown; parse?: (value: unknown) => unknown;
validate?: (value: unknown) => PluginConfigValidation; validate?: (value: unknown) => PluginConfigValidation;
uiHints?: Record<string, PluginConfigUiHint>; uiHints?: Record<string, PluginConfigUiHint>;
jsonSchema?: Record<string, unknown>;
}; };
export type ClawdbotPluginToolContext = { export type ClawdbotPluginToolContext = {

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

34
ui/src/ui/app-channels.ts Normal file
View File

@ -0,0 +1,34 @@
import {
loadChannels,
logoutWhatsApp,
startWhatsAppLogin,
waitWhatsAppLogin,
} from "./controllers/channels";
import { loadConfig, saveConfig } from "./controllers/config";
import type { ClawdbotApp } from "./app";
export async function handleWhatsAppStart(host: ClawdbotApp, force: boolean) {
await startWhatsAppLogin(host, force);
await loadChannels(host, true);
}
export async function handleWhatsAppWait(host: ClawdbotApp) {
await waitWhatsAppLogin(host);
await loadChannels(host, true);
}
export async function handleWhatsAppLogout(host: ClawdbotApp) {
await logoutWhatsApp(host);
await loadChannels(host, true);
}
export async function handleChannelConfigSave(host: ClawdbotApp) {
await saveConfig(host);
await loadConfig(host);
await loadChannels(host, true);
}
export async function handleChannelConfigReload(host: ClawdbotApp) {
await loadConfig(host);
await loadChannels(host, true);
}

View File

@ -1,58 +0,0 @@
import {
loadChannels,
logoutWhatsApp,
saveDiscordConfig,
saveIMessageConfig,
saveSlackConfig,
saveSignalConfig,
saveTelegramConfig,
startWhatsAppLogin,
waitWhatsAppLogin,
} from "./controllers/connections";
import { loadConfig } from "./controllers/config";
import type { ClawdbotApp } from "./app";
export async function handleWhatsAppStart(host: ClawdbotApp, force: boolean) {
await startWhatsAppLogin(host, force);
await loadChannels(host, true);
}
export async function handleWhatsAppWait(host: ClawdbotApp) {
await waitWhatsAppLogin(host);
await loadChannels(host, true);
}
export async function handleWhatsAppLogout(host: ClawdbotApp) {
await logoutWhatsApp(host);
await loadChannels(host, true);
}
export async function handleTelegramSave(host: ClawdbotApp) {
await saveTelegramConfig(host);
await loadConfig(host);
await loadChannels(host, true);
}
export async function handleDiscordSave(host: ClawdbotApp) {
await saveDiscordConfig(host);
await loadConfig(host);
await loadChannels(host, true);
}
export async function handleSlackSave(host: ClawdbotApp) {
await saveSlackConfig(host);
await loadConfig(host);
await loadChannels(host, true);
}
export async function handleSignalSave(host: ClawdbotApp) {
await saveSignalConfig(host);
await loadConfig(host);
await loadChannels(host, true);
}
export async function handleIMessageSave(host: ClawdbotApp) {
await saveIMessageConfig(host);
await loadConfig(host);
await loadChannels(host, true);
}

View File

@ -27,18 +27,10 @@ import type {
SkillStatusReport, SkillStatusReport,
StatusSummary, StatusSummary,
} from "./types"; } from "./types";
import type { import type { ChatQueueItem, CronFormState } from "./ui-types";
ChatQueueItem,
CronFormState,
DiscordForm,
IMessageForm,
SlackForm,
SignalForm,
TelegramForm,
} from "./ui-types";
import { renderChat } from "./views/chat"; import { renderChat } from "./views/chat";
import { renderConfig } from "./views/config"; import { renderConfig } from "./views/config";
import { renderConnections } from "./views/connections"; import { renderChannels } from "./views/channels";
import { renderCron } from "./views/cron"; import { renderCron } from "./views/cron";
import { renderDebug } from "./views/debug"; import { renderDebug } from "./views/debug";
import { renderInstances } from "./views/instances"; import { renderInstances } from "./views/instances";
@ -48,14 +40,7 @@ import { renderOverview } from "./views/overview";
import { renderSessions } from "./views/sessions"; import { renderSessions } from "./views/sessions";
import { renderSkills } from "./views/skills"; import { renderSkills } from "./views/skills";
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers"; import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
import { import { loadChannels } from "./controllers/channels";
loadChannels,
updateDiscordForm,
updateIMessageForm,
updateSlackForm,
updateSignalForm,
updateTelegramForm,
} from "./controllers/connections";
import { loadPresence } from "./controllers/presence"; import { loadPresence } from "./controllers/presence";
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions"; import { deleteSession, loadSessions, patchSession } from "./controllers/sessions";
import { import {
@ -205,8 +190,8 @@ export function renderApp(state: AppViewState) {
}) })
: nothing} : nothing}
${state.tab === "connections" ${state.tab === "channels"
? renderConnections({ ? renderChannels({
connected: state.connected, connected: state.connected,
loading: state.channelsLoading, loading: state.channelsLoading,
snapshot: state.channelsSnapshot, snapshot: state.channelsSnapshot,
@ -216,39 +201,19 @@ export function renderApp(state: AppViewState) {
whatsappQrDataUrl: state.whatsappLoginQrDataUrl, whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected, whatsappConnected: state.whatsappLoginConnected,
whatsappBusy: state.whatsappBusy, whatsappBusy: state.whatsappBusy,
telegramForm: state.telegramForm, configSchema: state.configSchema,
telegramTokenLocked: state.telegramTokenLocked, configSchemaLoading: state.configSchemaLoading,
telegramSaving: state.telegramSaving, configForm: state.configForm,
telegramStatus: state.telegramConfigStatus, configUiHints: state.configUiHints,
discordForm: state.discordForm, configSaving: state.configSaving,
discordTokenLocked: state.discordTokenLocked, configFormDirty: state.configFormDirty,
discordSaving: state.discordSaving,
discordStatus: state.discordConfigStatus,
slackForm: state.slackForm,
slackTokenLocked: state.slackTokenLocked,
slackAppTokenLocked: state.slackAppTokenLocked,
slackSaving: state.slackSaving,
slackStatus: state.slackConfigStatus,
signalForm: state.signalForm,
signalSaving: state.signalSaving,
signalStatus: state.signalConfigStatus,
imessageForm: state.imessageForm,
imessageSaving: state.imessageSaving,
imessageStatus: state.imessageConfigStatus,
onRefresh: (probe) => loadChannels(state, probe), onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force), onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(), onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(), onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onTelegramChange: (patch) => updateTelegramForm(state, patch), onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onTelegramSave: () => state.handleTelegramSave(), onConfigSave: () => state.handleChannelConfigSave(),
onDiscordChange: (patch) => updateDiscordForm(state, patch), onConfigReload: () => state.handleChannelConfigReload(),
onDiscordSave: () => state.handleDiscordSave(),
onSlackChange: (patch) => updateSlackForm(state, patch),
onSlackSave: () => state.handleSlackSave(),
onSignalChange: (patch) => updateSignalForm(state, patch),
onSignalSave: () => state.handleSignalSave(),
onIMessageChange: (patch) => updateIMessageForm(state, patch),
onIMessageSave: () => state.handleIMessageSave(),
}) })
: nothing} : nothing}

View File

@ -1,6 +1,6 @@
import { loadConfig, loadConfigSchema } from "./controllers/config"; import { loadConfig, loadConfigSchema } from "./controllers/config";
import { loadCronJobs, loadCronStatus } from "./controllers/cron"; import { loadCronJobs, loadCronStatus } from "./controllers/cron";
import { loadChannels } from "./controllers/connections"; import { loadChannels } from "./controllers/channels";
import { loadDebug } from "./controllers/debug"; import { loadDebug } from "./controllers/debug";
import { loadLogs } from "./controllers/logs"; import { loadLogs } from "./controllers/logs";
import { loadNodes } from "./controllers/nodes"; import { loadNodes } from "./controllers/nodes";
@ -125,7 +125,7 @@ export function setTheme(
export async function refreshActiveTab(host: SettingsHost) { export async function refreshActiveTab(host: SettingsHost) {
if (host.tab === "overview") await loadOverview(host); if (host.tab === "overview") await loadOverview(host);
if (host.tab === "connections") await loadConnections(host); if (host.tab === "channels") await loadChannelsTab(host);
if (host.tab === "instances") await loadPresence(host as unknown as ClawdbotApp); if (host.tab === "instances") await loadPresence(host as unknown as ClawdbotApp);
if (host.tab === "sessions") await loadSessions(host as unknown as ClawdbotApp); if (host.tab === "sessions") await loadSessions(host as unknown as ClawdbotApp);
if (host.tab === "cron") await loadCron(host); if (host.tab === "cron") await loadCron(host);
@ -256,9 +256,10 @@ export async function loadOverview(host: SettingsHost) {
]); ]);
} }
export async function loadConnections(host: SettingsHost) { export async function loadChannelsTab(host: SettingsHost) {
await Promise.all([ await Promise.all([
loadChannels(host as unknown as ClawdbotApp, true), loadChannels(host as unknown as ClawdbotApp, true),
loadConfigSchema(host as unknown as ClawdbotApp),
loadConfig(host as unknown as ClawdbotApp), loadConfig(host as unknown as ClawdbotApp),
]); ]);
} }

View File

@ -17,15 +17,7 @@ import type {
SkillStatusReport, SkillStatusReport,
StatusSummary, StatusSummary,
} from "./types"; } from "./types";
import type { import type { ChatQueueItem, CronFormState } from "./ui-types";
ChatQueueItem,
CronFormState,
DiscordForm,
IMessageForm,
SlackForm,
SignalForm,
TelegramForm,
} from "./ui-types";
import type { EventLogEntry } from "./app-events"; import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills"; import type { SkillMessage } from "./controllers/skills";
@ -73,25 +65,7 @@ export type AppViewState = {
whatsappLoginQrDataUrl: string | null; whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null; whatsappLoginConnected: boolean | null;
whatsappBusy: boolean; whatsappBusy: boolean;
telegramForm: TelegramForm; configFormDirty: boolean;
telegramSaving: boolean;
telegramTokenLocked: boolean;
telegramConfigStatus: string | null;
discordForm: DiscordForm;
discordSaving: boolean;
discordTokenLocked: boolean;
discordConfigStatus: string | null;
slackForm: SlackForm;
slackSaving: boolean;
slackTokenLocked: boolean;
slackAppTokenLocked: boolean;
slackConfigStatus: string | null;
signalForm: SignalForm;
signalSaving: boolean;
signalConfigStatus: string | null;
imessageForm: IMessageForm;
imessageSaving: boolean;
imessageConfigStatus: string | null;
presenceLoading: boolean; presenceLoading: boolean;
presenceEntries: PresenceEntry[]; presenceEntries: PresenceEntry[];
presenceError: string | null; presenceError: string | null;
@ -145,11 +119,8 @@ export type AppViewState = {
handleWhatsAppStart: (force: boolean) => Promise<void>; handleWhatsAppStart: (force: boolean) => Promise<void>;
handleWhatsAppWait: () => Promise<void>; handleWhatsAppWait: () => Promise<void>;
handleWhatsAppLogout: () => Promise<void>; handleWhatsAppLogout: () => Promise<void>;
handleTelegramSave: () => Promise<void>; handleChannelConfigSave: () => Promise<void>;
handleDiscordSave: () => Promise<void>; handleChannelConfigReload: () => Promise<void>;
handleSlackSave: () => Promise<void>;
handleSignalSave: () => Promise<void>;
handleIMessageSave: () => Promise<void>;
handleConfigLoad: () => Promise<void>; handleConfigLoad: () => Promise<void>;
handleConfigSave: () => Promise<void>; handleConfigSave: () => Promise<void>;
handleConfigApply: () => Promise<void>; handleConfigApply: () => Promise<void>;
@ -188,10 +159,5 @@ export type AppViewState = {
handleLogsLevelFilterToggle: (level: LogLevel) => void; handleLogsLevelFilterToggle: (level: LogLevel) => void;
handleLogsAutoFollowToggle: (next: boolean) => void; handleLogsAutoFollowToggle: (next: boolean) => void;
handleCallDebugMethod: (method: string, params: string) => Promise<void>; handleCallDebugMethod: (method: string, params: string) => Promise<void>;
handleUpdateDiscordForm: (path: string, value: unknown) => void;
handleUpdateSlackForm: (path: string, value: unknown) => void;
handleUpdateSignalForm: (path: string, value: unknown) => void;
handleUpdateTelegramForm: (path: string, value: unknown) => void;
handleUpdateIMessageForm: (path: string, value: unknown) => void;
}; };

View File

@ -21,17 +21,7 @@ import type {
SkillStatusReport, SkillStatusReport,
StatusSummary, StatusSummary,
} from "./types"; } from "./types";
import { import { type ChatQueueItem, type CronFormState } from "./ui-types";
defaultDiscordActions,
defaultSlackActions,
type ChatQueueItem,
type CronFormState,
type DiscordForm,
type IMessageForm,
type SlackForm,
type SignalForm,
type TelegramForm,
} from "./ui-types";
import type { EventLogEntry } from "./app-events"; import type { EventLogEntry } from "./app-events";
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults"; import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
import { import {
@ -66,15 +56,12 @@ import {
removeQueuedMessage as removeQueuedMessageInternal, removeQueuedMessage as removeQueuedMessageInternal,
} from "./app-chat"; } from "./app-chat";
import { import {
handleDiscordSave as handleDiscordSaveInternal, handleChannelConfigReload as handleChannelConfigReloadInternal,
handleIMessageSave as handleIMessageSaveInternal, handleChannelConfigSave as handleChannelConfigSaveInternal,
handleSignalSave as handleSignalSaveInternal,
handleSlackSave as handleSlackSaveInternal,
handleTelegramSave as handleTelegramSaveInternal,
handleWhatsAppLogout as handleWhatsAppLogoutInternal, handleWhatsAppLogout as handleWhatsAppLogoutInternal,
handleWhatsAppStart as handleWhatsAppStartInternal, handleWhatsAppStart as handleWhatsAppStartInternal,
handleWhatsAppWait as handleWhatsAppWaitInternal, handleWhatsAppWait as handleWhatsAppWaitInternal,
} from "./app-connections"; } from "./app-channels";
declare global { declare global {
interface Window { interface Window {
@ -143,91 +130,6 @@ export class ClawdbotApp extends LitElement {
@state() whatsappLoginQrDataUrl: string | null = null; @state() whatsappLoginQrDataUrl: string | null = null;
@state() whatsappLoginConnected: boolean | null = null; @state() whatsappLoginConnected: boolean | null = null;
@state() whatsappBusy = false; @state() whatsappBusy = false;
@state() telegramForm: TelegramForm = {
token: "",
requireMention: true,
groupsWildcardEnabled: false,
allowFrom: "",
proxy: "",
webhookUrl: "",
webhookSecret: "",
webhookPath: "",
};
@state() telegramSaving = false;
@state() telegramTokenLocked = false;
@state() telegramConfigStatus: string | null = null;
@state() discordForm: DiscordForm = {
enabled: true,
token: "",
dmEnabled: true,
allowFrom: "",
groupEnabled: false,
groupChannels: "",
mediaMaxMb: "",
historyLimit: "",
textChunkLimit: "",
guilds: [],
actions: { ...defaultDiscordActions },
slashEnabled: false,
slashName: "",
slashSessionPrefix: "",
slashEphemeral: true,
};
@state() discordSaving = false;
@state() discordTokenLocked = false;
@state() discordConfigStatus: string | null = null;
@state() slackForm: SlackForm = {
enabled: true,
botToken: "",
appToken: "",
dmEnabled: true,
allowFrom: "",
groupEnabled: false,
groupChannels: "",
mediaMaxMb: "",
textChunkLimit: "",
reactionNotifications: "own",
reactionAllowlist: "",
slashEnabled: false,
slashName: "",
slashSessionPrefix: "",
slashEphemeral: true,
actions: { ...defaultSlackActions },
channels: [],
};
@state() slackSaving = false;
@state() slackTokenLocked = false;
@state() slackAppTokenLocked = false;
@state() slackConfigStatus: string | null = null;
@state() signalForm: SignalForm = {
enabled: true,
account: "",
httpUrl: "",
httpHost: "",
httpPort: "",
cliPath: "",
autoStart: true,
receiveMode: "",
ignoreAttachments: false,
ignoreStories: false,
sendReadReceipts: false,
allowFrom: "",
mediaMaxMb: "",
};
@state() signalSaving = false;
@state() signalConfigStatus: string | null = null;
@state() imessageForm: IMessageForm = {
enabled: true,
cliPath: "",
dbPath: "",
service: "auto",
region: "",
allowFrom: "",
includeAttachments: false,
mediaMaxMb: "",
};
@state() imessageSaving = false;
@state() imessageConfigStatus: string | null = null;
@state() presenceLoading = false; @state() presenceLoading = false;
@state() presenceEntries: PresenceEntry[] = []; @state() presenceEntries: PresenceEntry[] = [];
@ -439,24 +341,12 @@ export class ClawdbotApp extends LitElement {
await handleWhatsAppLogoutInternal(this); await handleWhatsAppLogoutInternal(this);
} }
async handleTelegramSave() { async handleChannelConfigSave() {
await handleTelegramSaveInternal(this); await handleChannelConfigSaveInternal(this);
} }
async handleDiscordSave() { async handleChannelConfigReload() {
await handleDiscordSaveInternal(this); await handleChannelConfigReloadInternal(this);
}
async handleSlackSave() {
await handleSlackSaveInternal(this);
}
async handleSignalSave() {
await handleSignalSaveInternal(this);
}
async handleIMessageSave() {
await handleIMessageSaveInternal(this);
} }
// Sidebar handlers for tool output viewing // Sidebar handlers for tool output viewing

View File

@ -0,0 +1,76 @@
import type { ChannelsStatusSnapshot } from "../types";
import type { ChannelsState } from "./channels.types";
export type { ChannelsState };
export async function loadChannels(state: ChannelsState, probe: boolean) {
if (!state.client || !state.connected) return;
if (state.channelsLoading) return;
state.channelsLoading = true;
state.channelsError = null;
try {
const res = (await state.client.request("channels.status", {
probe,
timeoutMs: 8000,
})) as ChannelsStatusSnapshot;
state.channelsSnapshot = res;
state.channelsLastSuccess = Date.now();
} catch (err) {
state.channelsError = String(err);
} finally {
state.channelsLoading = false;
}
}
export async function startWhatsAppLogin(state: ChannelsState, force: boolean) {
if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true;
try {
const res = (await state.client.request("web.login.start", {
force,
timeoutMs: 30000,
})) as { message?: string; qrDataUrl?: string };
state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null;
state.whatsappLoginConnected = null;
} catch (err) {
state.whatsappLoginMessage = String(err);
state.whatsappLoginQrDataUrl = null;
state.whatsappLoginConnected = null;
} finally {
state.whatsappBusy = false;
}
}
export async function waitWhatsAppLogin(state: ChannelsState) {
if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true;
try {
const res = (await state.client.request("web.login.wait", {
timeoutMs: 120000,
})) as { connected?: boolean; message?: string };
state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginConnected = res.connected ?? null;
if (res.connected) state.whatsappLoginQrDataUrl = null;
} catch (err) {
state.whatsappLoginMessage = String(err);
state.whatsappLoginConnected = null;
} finally {
state.whatsappBusy = false;
}
}
export async function logoutWhatsApp(state: ChannelsState) {
if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true;
try {
await state.client.request("channels.logout", { channel: "whatsapp" });
state.whatsappLoginMessage = "Logged out.";
state.whatsappLoginQrDataUrl = null;
state.whatsappLoginConnected = null;
} catch (err) {
state.whatsappLoginMessage = String(err);
} finally {
state.whatsappBusy = false;
}
}

View File

@ -0,0 +1,15 @@
import type { GatewayBrowserClient } from "../gateway";
import type { ChannelsStatusSnapshot } from "../types";
export type ChannelsState = {
client: GatewayBrowserClient | null;
connected: boolean;
channelsLoading: boolean;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
channelsLastSuccess: number | null;
whatsappLoginMessage: string | null;
whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null;
whatsappBusy: boolean;
};

View File

@ -7,92 +7,6 @@ import {
updateConfigFormValue, updateConfigFormValue,
type ConfigState, type ConfigState,
} from "./config"; } from "./config";
import {
defaultDiscordActions,
defaultSlackActions,
type DiscordForm,
type IMessageForm,
type SignalForm,
type SlackForm,
type TelegramForm,
} from "../ui-types";
const baseTelegramForm: TelegramForm = {
token: "",
requireMention: true,
groupsWildcardEnabled: false,
allowFrom: "",
proxy: "",
webhookUrl: "",
webhookSecret: "",
webhookPath: "",
};
const baseDiscordForm: DiscordForm = {
enabled: true,
token: "",
dmEnabled: true,
allowFrom: "",
groupEnabled: false,
groupChannels: "",
mediaMaxMb: "",
historyLimit: "",
textChunkLimit: "",
replyToMode: "off",
guilds: [],
actions: { ...defaultDiscordActions },
slashEnabled: false,
slashName: "",
slashSessionPrefix: "",
slashEphemeral: true,
};
const baseSlackForm: SlackForm = {
enabled: true,
botToken: "",
appToken: "",
dmEnabled: true,
allowFrom: "",
groupEnabled: false,
groupChannels: "",
mediaMaxMb: "",
textChunkLimit: "",
reactionNotifications: "own",
reactionAllowlist: "",
slashEnabled: false,
slashName: "",
slashSessionPrefix: "",
slashEphemeral: true,
actions: { ...defaultSlackActions },
channels: [],
};
const baseSignalForm: SignalForm = {
enabled: true,
account: "",
httpUrl: "",
httpHost: "",
httpPort: "",
cliPath: "",
autoStart: true,
receiveMode: "",
ignoreAttachments: false,
ignoreStories: false,
sendReadReceipts: false,
allowFrom: "",
mediaMaxMb: "",
};
const baseIMessageForm: IMessageForm = {
enabled: true,
cliPath: "",
dbPath: "",
service: "auto",
region: "",
allowFrom: "",
includeAttachments: false,
mediaMaxMb: "",
};
function createState(): ConfigState { function createState(): ConfigState {
return { return {
@ -115,40 +29,10 @@ function createState(): ConfigState {
configFormDirty: false, configFormDirty: false,
configFormMode: "form", configFormMode: "form",
lastError: null, lastError: null,
telegramForm: { ...baseTelegramForm },
discordForm: { ...baseDiscordForm },
slackForm: { ...baseSlackForm },
signalForm: { ...baseSignalForm },
imessageForm: { ...baseIMessageForm },
telegramConfigStatus: null,
discordConfigStatus: null,
slackConfigStatus: null,
signalConfigStatus: null,
imessageConfigStatus: null,
}; };
} }
describe("applyConfigSnapshot", () => { describe("applyConfigSnapshot", () => {
it("handles missing slack config without throwing", () => {
const state = createState();
applyConfigSnapshot(state, {
config: {
channels: {
telegram: {},
discord: {},
signal: {},
imessage: {},
},
},
valid: true,
issues: [],
raw: "{}",
});
expect(state.slackForm.botToken).toBe("");
expect(state.slackForm.actions).toEqual(defaultSlackActions);
});
it("does not clobber form edits while dirty", () => { it("does not clobber form edits while dirty", () => {
const state = createState(); const state = createState();
state.configFormMode = "form"; state.configFormMode = "form";
@ -167,6 +51,18 @@ describe("applyConfigSnapshot", () => {
"{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n", "{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n",
); );
}); });
it("updates config form when clean", () => {
const state = createState();
applyConfigSnapshot(state, {
config: { gateway: { mode: "local" } },
valid: true,
issues: [],
raw: "{}",
});
expect(state.configForm).toEqual({ gateway: { mode: "local" } });
});
}); });
describe("updateConfigFormValue", () => { describe("updateConfigFormValue", () => {

View File

@ -4,19 +4,6 @@ import type {
ConfigSnapshot, ConfigSnapshot,
ConfigUiHints, ConfigUiHints,
} from "../types"; } from "../types";
import {
defaultDiscordActions,
defaultSlackActions,
type DiscordActionForm,
type DiscordForm,
type DiscordGuildChannelForm,
type DiscordGuildForm,
type IMessageForm,
type SlackChannelForm,
type SlackForm,
type SignalForm,
type TelegramForm,
} from "../ui-types";
import { import {
cloneConfigObject, cloneConfigObject,
removePathValue, removePathValue,
@ -44,16 +31,6 @@ export type ConfigState = {
configFormDirty: boolean; configFormDirty: boolean;
configFormMode: "form" | "raw"; configFormMode: "form" | "raw";
lastError: string | null; lastError: string | null;
telegramForm: TelegramForm;
discordForm: DiscordForm;
slackForm: SlackForm;
signalForm: SignalForm;
imessageForm: IMessageForm;
telegramConfigStatus: string | null;
discordConfigStatus: string | null;
slackConfigStatus: string | null;
signalConfigStatus: string | null;
imessageConfigStatus: string | null;
}; };
export async function loadConfig(state: ConfigState) { export async function loadConfig(state: ConfigState) {
@ -114,285 +91,6 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null; state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null;
state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : []; state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : [];
const config = snapshot.config ?? {};
const channels = (config.channels ?? {}) as Record<string, unknown>;
const telegram = (channels.telegram ?? config.telegram ?? {}) as Record<string, unknown>;
const discord = (channels.discord ?? config.discord ?? {}) as Record<string, unknown>;
const slack = (channels.slack ?? config.slack ?? {}) as Record<string, unknown>;
const signal = (channels.signal ?? config.signal ?? {}) as Record<string, unknown>;
const imessage = (channels.imessage ?? config.imessage ?? {}) as Record<string, unknown>;
const toList = (value: unknown) =>
Array.isArray(value)
? value
.map((v) => String(v ?? "").trim())
.filter((v) => v.length > 0)
.join(", ")
: "";
const telegramGroups =
telegram.groups && typeof telegram.groups === "object"
? (telegram.groups as Record<string, unknown>)
: {};
const telegramDefaultGroup =
telegramGroups["*"] && typeof telegramGroups["*"] === "object"
? (telegramGroups["*"] as Record<string, unknown>)
: {};
const telegramHasWildcard = Boolean(telegramGroups["*"]);
const allowFrom = Array.isArray(telegram.allowFrom)
? toList(telegram.allowFrom)
: typeof telegram.allowFrom === "string"
? telegram.allowFrom
: "";
state.telegramForm = {
token: typeof telegram.botToken === "string" ? telegram.botToken : "",
requireMention:
typeof telegramDefaultGroup.requireMention === "boolean"
? telegramDefaultGroup.requireMention
: true,
groupsWildcardEnabled: telegramHasWildcard,
allowFrom,
proxy: typeof telegram.proxy === "string" ? telegram.proxy : "",
webhookUrl: typeof telegram.webhookUrl === "string" ? telegram.webhookUrl : "",
webhookSecret:
typeof telegram.webhookSecret === "string" ? telegram.webhookSecret : "",
webhookPath: typeof telegram.webhookPath === "string" ? telegram.webhookPath : "",
};
const discordDm = (discord.dm ?? {}) as Record<string, unknown>;
const slash = (discord.slashCommand ?? {}) as Record<string, unknown>;
const discordActions = (discord.actions ?? {}) as Record<string, unknown>;
const discordGuilds = discord.guilds;
const readAction = (key: keyof DiscordActionForm) =>
typeof discordActions[key] === "boolean"
? (discordActions[key] as boolean)
: defaultDiscordActions[key];
state.discordForm = {
enabled: typeof discord.enabled === "boolean" ? discord.enabled : true,
token: typeof discord.token === "string" ? discord.token : "",
dmEnabled: typeof discordDm.enabled === "boolean" ? discordDm.enabled : true,
allowFrom: toList(discordDm.allowFrom),
groupEnabled:
typeof discordDm.groupEnabled === "boolean" ? discordDm.groupEnabled : false,
groupChannels: toList(discordDm.groupChannels),
mediaMaxMb:
typeof discord.mediaMaxMb === "number" ? String(discord.mediaMaxMb) : "",
historyLimit:
typeof discord.historyLimit === "number" ? String(discord.historyLimit) : "",
textChunkLimit:
typeof discord.textChunkLimit === "number"
? String(discord.textChunkLimit)
: "",
replyToMode:
discord.replyToMode === "first" || discord.replyToMode === "all"
? discord.replyToMode
: "off",
guilds: Array.isArray(discordGuilds)
? []
: typeof discordGuilds === "object" && discordGuilds
? Object.entries(discordGuilds as Record<string, unknown>).map(
([key, value]): DiscordGuildForm => {
const entry =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
const channelsRaw =
entry.channels && typeof entry.channels === "object"
? (entry.channels as Record<string, unknown>)
: {};
const channels = Object.entries(channelsRaw).map(
([channelKey, channelValue]): DiscordGuildChannelForm => {
const channel =
channelValue && typeof channelValue === "object"
? (channelValue as Record<string, unknown>)
: {};
return {
key: channelKey,
allow:
typeof channel.allow === "boolean" ? channel.allow : true,
requireMention:
typeof channel.requireMention === "boolean"
? channel.requireMention
: false,
};
},
);
return {
key,
slug: typeof entry.slug === "string" ? entry.slug : "",
requireMention:
typeof entry.requireMention === "boolean"
? entry.requireMention
: false,
reactionNotifications:
entry.reactionNotifications === "off" ||
entry.reactionNotifications === "all" ||
entry.reactionNotifications === "own" ||
entry.reactionNotifications === "allowlist"
? entry.reactionNotifications
: "own",
users: toList(entry.users),
channels,
};
},
)
: [],
actions: {
reactions: readAction("reactions"),
stickers: readAction("stickers"),
polls: readAction("polls"),
permissions: readAction("permissions"),
messages: readAction("messages"),
threads: readAction("threads"),
pins: readAction("pins"),
search: readAction("search"),
memberInfo: readAction("memberInfo"),
roleInfo: readAction("roleInfo"),
channelInfo: readAction("channelInfo"),
voiceStatus: readAction("voiceStatus"),
events: readAction("events"),
roles: readAction("roles"),
moderation: readAction("moderation"),
},
slashEnabled: typeof slash.enabled === "boolean" ? slash.enabled : false,
slashName: typeof slash.name === "string" ? slash.name : "",
slashSessionPrefix:
typeof slash.sessionPrefix === "string" ? slash.sessionPrefix : "",
slashEphemeral:
typeof slash.ephemeral === "boolean" ? slash.ephemeral : true,
};
const slackDm = (slack.dm ?? {}) as Record<string, unknown>;
const slackChannels = slack.channels;
const slackSlash = (slack.slashCommand ?? {}) as Record<string, unknown>;
const slackActions =
(slack.actions ?? {}) as Partial<Record<keyof typeof defaultSlackActions, unknown>>;
state.slackForm = {
enabled: typeof slack.enabled === "boolean" ? slack.enabled : true,
botToken: typeof slack.botToken === "string" ? slack.botToken : "",
appToken: typeof slack.appToken === "string" ? slack.appToken : "",
dmEnabled: typeof slackDm.enabled === "boolean" ? slackDm.enabled : true,
allowFrom: toList(slackDm.allowFrom),
groupEnabled:
typeof slackDm.groupEnabled === "boolean" ? slackDm.groupEnabled : false,
groupChannels: toList(slackDm.groupChannels),
mediaMaxMb:
typeof slack.mediaMaxMb === "number" ? String(slack.mediaMaxMb) : "",
textChunkLimit:
typeof slack.textChunkLimit === "number"
? String(slack.textChunkLimit)
: "",
reactionNotifications:
slack.reactionNotifications === "off" ||
slack.reactionNotifications === "all" ||
slack.reactionNotifications === "allowlist"
? slack.reactionNotifications
: "own",
reactionAllowlist: toList(slack.reactionAllowlist),
slashEnabled:
typeof slackSlash.enabled === "boolean" ? slackSlash.enabled : false,
slashName: typeof slackSlash.name === "string" ? slackSlash.name : "",
slashSessionPrefix:
typeof slackSlash.sessionPrefix === "string"
? slackSlash.sessionPrefix
: "",
slashEphemeral:
typeof slackSlash.ephemeral === "boolean" ? slackSlash.ephemeral : true,
actions: {
...defaultSlackActions,
reactions:
typeof slackActions.reactions === "boolean"
? slackActions.reactions
: defaultSlackActions.reactions,
messages:
typeof slackActions.messages === "boolean"
? slackActions.messages
: defaultSlackActions.messages,
pins:
typeof slackActions.pins === "boolean"
? slackActions.pins
: defaultSlackActions.pins,
memberInfo:
typeof slackActions.memberInfo === "boolean"
? slackActions.memberInfo
: defaultSlackActions.memberInfo,
emojiList:
typeof slackActions.emojiList === "boolean"
? slackActions.emojiList
: defaultSlackActions.emojiList,
},
channels: Array.isArray(slackChannels)
? []
: typeof slackChannels === "object" && slackChannels
? Object.entries(slackChannels as Record<string, unknown>).map(
([key, value]): SlackChannelForm => {
const entry =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
return {
key,
allow:
typeof entry.allow === "boolean" ? entry.allow : true,
requireMention:
typeof entry.requireMention === "boolean"
? entry.requireMention
: false,
};
},
)
: [],
};
state.signalForm = {
enabled: typeof signal.enabled === "boolean" ? signal.enabled : true,
account: typeof signal.account === "string" ? signal.account : "",
httpUrl: typeof signal.httpUrl === "string" ? signal.httpUrl : "",
httpHost: typeof signal.httpHost === "string" ? signal.httpHost : "",
httpPort: typeof signal.httpPort === "number" ? String(signal.httpPort) : "",
cliPath: typeof signal.cliPath === "string" ? signal.cliPath : "",
autoStart: typeof signal.autoStart === "boolean" ? signal.autoStart : true,
receiveMode:
signal.receiveMode === "on-start" || signal.receiveMode === "manual"
? signal.receiveMode
: "",
ignoreAttachments:
typeof signal.ignoreAttachments === "boolean" ? signal.ignoreAttachments : false,
ignoreStories:
typeof signal.ignoreStories === "boolean" ? signal.ignoreStories : false,
sendReadReceipts:
typeof signal.sendReadReceipts === "boolean" ? signal.sendReadReceipts : false,
allowFrom: toList(signal.allowFrom),
mediaMaxMb:
typeof signal.mediaMaxMb === "number" ? String(signal.mediaMaxMb) : "",
};
state.imessageForm = {
enabled: typeof imessage.enabled === "boolean" ? imessage.enabled : true,
cliPath: typeof imessage.cliPath === "string" ? imessage.cliPath : "",
dbPath: typeof imessage.dbPath === "string" ? imessage.dbPath : "",
service:
imessage.service === "imessage" ||
imessage.service === "sms" ||
imessage.service === "auto"
? imessage.service
: "auto",
region: typeof imessage.region === "string" ? imessage.region : "",
allowFrom: toList(imessage.allowFrom),
includeAttachments:
typeof imessage.includeAttachments === "boolean"
? imessage.includeAttachments
: false,
mediaMaxMb:
typeof imessage.mediaMaxMb === "number" ? String(imessage.mediaMaxMb) : "",
};
const configInvalid = snapshot.valid === false ? "Config invalid." : null;
state.telegramConfigStatus = configInvalid;
state.discordConfigStatus = configInvalid;
state.slackConfigStatus = configInvalid;
state.signalConfigStatus = configInvalid;
state.imessageConfigStatus = configInvalid;
if (!state.configFormDirty) { if (!state.configFormDirty) {
state.configForm = cloneConfigObject(snapshot.config ?? {}); state.configForm = cloneConfigObject(snapshot.config ?? {});
} }

View File

@ -1,173 +0,0 @@
import { parseList } from "../format";
import {
defaultDiscordActions,
type DiscordActionForm,
type DiscordGuildChannelForm,
type DiscordGuildForm,
} from "../ui-types";
import type { ConnectionsState } from "./connections.types";
export async function saveDiscordConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.discordSaving) return;
state.discordSaving = true;
state.discordConfigStatus = null;
try {
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.discordConfigStatus = "Config hash missing; reload and retry.";
return;
}
const discord: Record<string, unknown> = {};
const form = state.discordForm;
if (form.enabled) {
discord.enabled = null;
} else {
discord.enabled = false;
}
if (!state.discordTokenLocked) {
const token = form.token.trim();
discord.token = token || null;
}
const allowFrom = parseList(form.allowFrom);
const groupChannels = parseList(form.groupChannels);
const dm: Record<string, unknown> = {
enabled: form.dmEnabled ? null : false,
allowFrom: allowFrom.length > 0 ? allowFrom : null,
groupEnabled: form.groupEnabled ? true : null,
groupChannels: groupChannels.length > 0 ? groupChannels : null,
};
discord.dm = dm;
const mediaMaxMb = Number(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
discord.mediaMaxMb = mediaMaxMb;
} else {
discord.mediaMaxMb = null;
}
const historyLimitRaw = form.historyLimit.trim();
if (historyLimitRaw.length === 0) {
discord.historyLimit = null;
} else {
const historyLimit = Number(historyLimitRaw);
if (Number.isFinite(historyLimit) && historyLimit >= 0) {
discord.historyLimit = historyLimit;
} else {
discord.historyLimit = null;
}
}
const chunkLimitRaw = form.textChunkLimit.trim();
if (chunkLimitRaw.length === 0) {
discord.textChunkLimit = null;
} else {
const chunkLimit = Number(chunkLimitRaw);
if (Number.isFinite(chunkLimit) && chunkLimit > 0) {
discord.textChunkLimit = chunkLimit;
} else {
discord.textChunkLimit = null;
}
}
if (form.replyToMode === "off") {
discord.replyToMode = null;
} else {
discord.replyToMode = form.replyToMode;
}
const guildsForm = Array.isArray(form.guilds) ? form.guilds : [];
const guilds: Record<string, unknown> = {};
guildsForm.forEach((guild: DiscordGuildForm) => {
const key = String(guild.key ?? "").trim();
if (!key) return;
const entry: Record<string, unknown> = {};
const slug = String(guild.slug ?? "").trim();
if (slug) entry.slug = slug;
if (guild.requireMention) entry.requireMention = true;
if (
guild.reactionNotifications === "off" ||
guild.reactionNotifications === "all" ||
guild.reactionNotifications === "own" ||
guild.reactionNotifications === "allowlist"
) {
entry.reactionNotifications = guild.reactionNotifications;
}
const users = parseList(guild.users);
if (users.length > 0) entry.users = users;
const channels: Record<string, unknown> = {};
const channelForms = Array.isArray(guild.channels) ? guild.channels : [];
channelForms.forEach((channel: DiscordGuildChannelForm) => {
const channelKey = String(channel.key ?? "").trim();
if (!channelKey) return;
const channelEntry: Record<string, unknown> = {};
if (channel.allow === false) channelEntry.allow = false;
if (channel.requireMention) channelEntry.requireMention = true;
channels[channelKey] = channelEntry;
});
if (Object.keys(channels).length > 0) entry.channels = channels;
guilds[key] = entry;
});
if (Object.keys(guilds).length > 0) discord.guilds = guilds;
else discord.guilds = null;
const actions: Partial<DiscordActionForm> = {};
const applyAction = (key: keyof DiscordActionForm) => {
const value = form.actions[key];
if (value !== defaultDiscordActions[key]) actions[key] = value;
};
applyAction("reactions");
applyAction("stickers");
applyAction("polls");
applyAction("permissions");
applyAction("messages");
applyAction("threads");
applyAction("pins");
applyAction("search");
applyAction("memberInfo");
applyAction("roleInfo");
applyAction("channelInfo");
applyAction("voiceStatus");
applyAction("events");
applyAction("roles");
applyAction("moderation");
if (Object.keys(actions).length > 0) {
discord.actions = actions;
} else {
discord.actions = null;
}
const slash = { ...(discord.slashCommand ?? {}) } as Record<string, unknown>;
if (form.slashEnabled) {
slash.enabled = true;
} else {
slash.enabled = null;
}
if (form.slashName.trim()) slash.name = form.slashName.trim();
else slash.name = null;
if (form.slashSessionPrefix.trim())
slash.sessionPrefix = form.slashSessionPrefix.trim();
else slash.sessionPrefix = null;
if (form.slashEphemeral) {
slash.ephemeral = null;
} else {
slash.ephemeral = false;
}
discord.slashCommand = Object.keys(slash).length > 0 ? slash : null;
const raw = `${JSON.stringify(
{ channels: { discord } },
null,
2,
).trimEnd()}\n`;
await state.client.request("config.patch", { raw, baseHash });
state.discordConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.discordConfigStatus = String(err);
} finally {
state.discordSaving = false;
}
}

View File

@ -1,63 +0,0 @@
import { parseList } from "../format";
import type { ConnectionsState } from "./connections.types";
export async function saveIMessageConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.imessageSaving) return;
state.imessageSaving = true;
state.imessageConfigStatus = null;
try {
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.imessageConfigStatus = "Config hash missing; reload and retry.";
return;
}
const imessage: Record<string, unknown> = {};
const form = state.imessageForm;
if (form.enabled) {
imessage.enabled = null;
} else {
imessage.enabled = false;
}
const cliPath = form.cliPath.trim();
imessage.cliPath = cliPath || null;
const dbPath = form.dbPath.trim();
imessage.dbPath = dbPath || null;
if (form.service === "auto") {
imessage.service = null;
} else {
imessage.service = form.service;
}
const region = form.region.trim();
imessage.region = region || null;
const allowFrom = parseList(form.allowFrom);
imessage.allowFrom = allowFrom.length > 0 ? allowFrom : null;
imessage.includeAttachments = form.includeAttachments ? true : null;
const mediaMaxMb = Number(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
imessage.mediaMaxMb = mediaMaxMb;
} else {
imessage.mediaMaxMb = null;
}
const raw = `${JSON.stringify(
{ channels: { imessage } },
null,
2,
).trimEnd()}\n`;
await state.client.request("config.patch", { raw, baseHash });
state.imessageConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.imessageConfigStatus = String(err);
} finally {
state.imessageSaving = false;
}
}

View File

@ -1,81 +0,0 @@
import { parseList } from "../format";
import type { ConnectionsState } from "./connections.types";
export async function saveSignalConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.signalSaving) return;
state.signalSaving = true;
state.signalConfigStatus = null;
try {
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.signalConfigStatus = "Config hash missing; reload and retry.";
return;
}
const signal: Record<string, unknown> = {};
const form = state.signalForm;
if (form.enabled) {
signal.enabled = null;
} else {
signal.enabled = false;
}
const account = form.account.trim();
signal.account = account || null;
const httpUrl = form.httpUrl.trim();
signal.httpUrl = httpUrl || null;
const httpHost = form.httpHost.trim();
signal.httpHost = httpHost || null;
const httpPort = Number(form.httpPort);
if (Number.isFinite(httpPort) && httpPort > 0) {
signal.httpPort = httpPort;
} else {
signal.httpPort = null;
}
const cliPath = form.cliPath.trim();
signal.cliPath = cliPath || null;
if (form.autoStart) {
signal.autoStart = null;
} else {
signal.autoStart = false;
}
if (form.receiveMode === "on-start" || form.receiveMode === "manual") {
signal.receiveMode = form.receiveMode;
} else {
signal.receiveMode = null;
}
signal.ignoreAttachments = form.ignoreAttachments ? true : null;
signal.ignoreStories = form.ignoreStories ? true : null;
signal.sendReadReceipts = form.sendReadReceipts ? true : null;
const allowFrom = parseList(form.allowFrom);
signal.allowFrom = allowFrom.length > 0 ? allowFrom : null;
const mediaMaxMb = Number(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
signal.mediaMaxMb = mediaMaxMb;
} else {
signal.mediaMaxMb = null;
}
const raw = `${JSON.stringify(
{ channels: { signal } },
null,
2,
).trimEnd()}\n`;
await state.client.request("config.patch", { raw, baseHash });
state.signalConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.signalConfigStatus = String(err);
} finally {
state.signalSaving = false;
}
}

View File

@ -1,138 +0,0 @@
import { parseList } from "../format";
import { defaultSlackActions, type SlackActionForm } from "../ui-types";
import type { ConnectionsState } from "./connections.types";
export async function saveSlackConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.slackSaving) return;
state.slackSaving = true;
state.slackConfigStatus = null;
try {
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.slackConfigStatus = "Config hash missing; reload and retry.";
return;
}
const slack: Record<string, unknown> = {};
const form = state.slackForm;
if (form.enabled) {
slack.enabled = null;
} else {
slack.enabled = false;
}
if (!state.slackTokenLocked) {
const token = form.botToken.trim();
slack.botToken = token || null;
}
if (!state.slackAppTokenLocked) {
const token = form.appToken.trim();
slack.appToken = token || null;
}
const dm: Record<string, unknown> = {};
dm.enabled = form.dmEnabled;
const allowFrom = parseList(form.allowFrom);
dm.allowFrom = allowFrom.length > 0 ? allowFrom : null;
if (form.groupEnabled) {
dm.groupEnabled = true;
} else {
dm.groupEnabled = null;
}
const groupChannels = parseList(form.groupChannels);
dm.groupChannels = groupChannels.length > 0 ? groupChannels : null;
slack.dm = dm;
const mediaMaxMb = Number.parseFloat(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
slack.mediaMaxMb = mediaMaxMb;
} else {
slack.mediaMaxMb = null;
}
const textChunkLimit = Number.parseInt(form.textChunkLimit, 10);
if (Number.isFinite(textChunkLimit) && textChunkLimit > 0) {
slack.textChunkLimit = textChunkLimit;
} else {
slack.textChunkLimit = null;
}
if (form.reactionNotifications === "own") {
slack.reactionNotifications = null;
} else {
slack.reactionNotifications = form.reactionNotifications;
}
const reactionAllowlist = parseList(form.reactionAllowlist);
if (reactionAllowlist.length > 0) {
slack.reactionAllowlist = reactionAllowlist;
} else {
slack.reactionAllowlist = null;
}
const slash: Record<string, unknown> = {};
if (form.slashEnabled) {
slash.enabled = true;
} else {
slash.enabled = null;
}
if (form.slashName.trim()) slash.name = form.slashName.trim();
else slash.name = null;
if (form.slashSessionPrefix.trim())
slash.sessionPrefix = form.slashSessionPrefix.trim();
else slash.sessionPrefix = null;
if (form.slashEphemeral) {
slash.ephemeral = null;
} else {
slash.ephemeral = false;
}
slack.slashCommand = slash;
const actions: Partial<SlackActionForm> = {};
const applyAction = (key: keyof SlackActionForm) => {
const value = form.actions[key];
if (value !== defaultSlackActions[key]) actions[key] = value;
};
applyAction("reactions");
applyAction("messages");
applyAction("pins");
applyAction("memberInfo");
applyAction("emojiList");
if (Object.keys(actions).length > 0) {
slack.actions = actions;
} else {
slack.actions = null;
}
const channels = form.channels
.map((entry): [string, Record<string, unknown>] | null => {
const key = entry.key.trim();
if (!key) return null;
const record: Record<string, unknown> = {
allow: entry.allow,
requireMention: entry.requireMention,
};
return [key, record];
})
.filter((value): value is [string, Record<string, unknown>] =>
Boolean(value),
);
if (channels.length > 0) {
slack.channels = Object.fromEntries(channels);
} else {
slack.channels = null;
}
const raw = `${JSON.stringify(
{ channels: { slack } },
null,
2,
).trimEnd()}\n`;
await state.client.request("config.patch", { raw, baseHash });
state.slackConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.slackConfigStatus = String(err);
} finally {
state.slackSaving = false;
}
}

View File

@ -1,221 +0,0 @@
import { parseList } from "../format";
import type { ChannelsStatusSnapshot } from "../types";
import {
type DiscordForm,
type IMessageForm,
type SlackForm,
type SignalForm,
type TelegramForm,
} from "../ui-types";
import type { ConnectionsState } from "./connections.types";
export { saveDiscordConfig } from "./connections.save-discord";
export { saveIMessageConfig } from "./connections.save-imessage";
export { saveSlackConfig } from "./connections.save-slack";
export { saveSignalConfig } from "./connections.save-signal";
export type { ConnectionsState };
export async function loadChannels(state: ConnectionsState, probe: boolean) {
if (!state.client || !state.connected) return;
if (state.channelsLoading) return;
state.channelsLoading = true;
state.channelsError = null;
try {
const res = (await state.client.request("channels.status", {
probe,
timeoutMs: 8000,
})) as ChannelsStatusSnapshot;
state.channelsSnapshot = res;
state.channelsLastSuccess = Date.now();
const channels = res.channels as Record<string, unknown>;
const telegram = channels.telegram as { tokenSource?: string | null };
const discord = channels.discord as { tokenSource?: string | null } | null;
const slack = channels.slack as
| { botTokenSource?: string | null; appTokenSource?: string | null }
| null;
state.telegramTokenLocked = telegram?.tokenSource === "env";
state.discordTokenLocked = discord?.tokenSource === "env";
state.slackTokenLocked = slack?.botTokenSource === "env";
state.slackAppTokenLocked = slack?.appTokenSource === "env";
} catch (err) {
state.channelsError = String(err);
} finally {
state.channelsLoading = false;
}
}
export async function startWhatsAppLogin(state: ConnectionsState, force: boolean) {
if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true;
try {
const res = (await state.client.request("web.login.start", {
force,
timeoutMs: 30000,
})) as { message?: string; qrDataUrl?: string };
state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null;
state.whatsappLoginConnected = null;
} catch (err) {
state.whatsappLoginMessage = String(err);
state.whatsappLoginQrDataUrl = null;
state.whatsappLoginConnected = null;
} finally {
state.whatsappBusy = false;
}
}
export async function waitWhatsAppLogin(state: ConnectionsState) {
if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true;
try {
const res = (await state.client.request("web.login.wait", {
timeoutMs: 120000,
})) as { connected?: boolean; message?: string };
state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginConnected = res.connected ?? null;
if (res.connected) state.whatsappLoginQrDataUrl = null;
} catch (err) {
state.whatsappLoginMessage = String(err);
state.whatsappLoginConnected = null;
} finally {
state.whatsappBusy = false;
}
}
export async function logoutWhatsApp(state: ConnectionsState) {
if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true;
try {
await state.client.request("channels.logout", { channel: "whatsapp" });
state.whatsappLoginMessage = "Logged out.";
state.whatsappLoginQrDataUrl = null;
state.whatsappLoginConnected = null;
} catch (err) {
state.whatsappLoginMessage = String(err);
} finally {
state.whatsappBusy = false;
}
}
export function updateTelegramForm(
state: ConnectionsState,
patch: Partial<TelegramForm>,
) {
state.telegramForm = { ...state.telegramForm, ...patch };
}
export function updateDiscordForm(
state: ConnectionsState,
patch: Partial<DiscordForm>,
) {
if (patch.actions) {
state.discordForm = {
...state.discordForm,
...patch,
actions: { ...state.discordForm.actions, ...patch.actions },
};
return;
}
state.discordForm = { ...state.discordForm, ...patch };
}
export function updateSlackForm(
state: ConnectionsState,
patch: Partial<SlackForm>,
) {
if (patch.actions) {
state.slackForm = {
...state.slackForm,
...patch,
actions: { ...state.slackForm.actions, ...patch.actions },
};
return;
}
state.slackForm = { ...state.slackForm, ...patch };
}
export function updateSignalForm(
state: ConnectionsState,
patch: Partial<SignalForm>,
) {
state.signalForm = { ...state.signalForm, ...patch };
}
export function updateIMessageForm(
state: ConnectionsState,
patch: Partial<IMessageForm>,
) {
state.imessageForm = { ...state.imessageForm, ...patch };
}
export async function saveTelegramConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.telegramSaving) return;
state.telegramSaving = true;
state.telegramConfigStatus = null;
try {
if (state.telegramForm.groupsWildcardEnabled) {
const confirmed = window.confirm(
'Telegram groups wildcard "*" allows all groups. Continue?',
);
if (!confirmed) {
state.telegramConfigStatus = "Save cancelled.";
return;
}
}
const base = state.configSnapshot?.config ?? {};
const channels = (base.channels ?? {}) as Record<string, unknown>;
const telegram = {
...(channels.telegram ?? base.telegram ?? {}),
} as Record<string, unknown>;
if (!state.telegramTokenLocked) {
const token = state.telegramForm.token.trim();
telegram.botToken = token || null;
}
const groupsPatch: Record<string, unknown> = {};
if (state.telegramForm.groupsWildcardEnabled) {
const existingGroups = telegram.groups as Record<string, unknown> | undefined;
const defaultGroup =
existingGroups?.["*"] && typeof existingGroups["*"] === "object"
? ({ ...(existingGroups["*"] as Record<string, unknown>) } as Record<
string,
unknown
>)
: {};
defaultGroup.requireMention = state.telegramForm.requireMention;
groupsPatch["*"] = defaultGroup;
} else {
groupsPatch["*"] = null;
}
telegram.groups = groupsPatch;
telegram.requireMention = null;
const allowFrom = parseList(state.telegramForm.allowFrom);
telegram.allowFrom = allowFrom.length > 0 ? allowFrom : null;
const proxy = state.telegramForm.proxy.trim();
telegram.proxy = proxy || null;
const webhookUrl = state.telegramForm.webhookUrl.trim();
telegram.webhookUrl = webhookUrl || null;
const webhookSecret = state.telegramForm.webhookSecret.trim();
telegram.webhookSecret = webhookSecret || null;
const webhookPath = state.telegramForm.webhookPath.trim();
telegram.webhookPath = webhookPath || null;
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.telegramConfigStatus = "Config hash missing; reload and retry.";
return;
}
const raw = `${JSON.stringify(
{ channels: { telegram } },
null,
2,
).trimEnd()}\n`;
await state.client.request("config.patch", { raw, baseHash });
state.telegramConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.telegramConfigStatus = String(err);
} finally {
state.telegramSaving = false;
}
}

View File

@ -1,43 +0,0 @@
import type { GatewayBrowserClient } from "../gateway";
import type { ChannelsStatusSnapshot, ConfigSnapshot } from "../types";
import type {
DiscordForm,
IMessageForm,
SlackForm,
SignalForm,
TelegramForm,
} from "../ui-types";
export type ConnectionsState = {
client: GatewayBrowserClient | null;
connected: boolean;
channelsLoading: boolean;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
channelsLastSuccess: number | null;
whatsappLoginMessage: string | null;
whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null;
whatsappBusy: boolean;
telegramForm: TelegramForm;
telegramSaving: boolean;
telegramTokenLocked: boolean;
telegramConfigStatus: string | null;
discordForm: DiscordForm;
discordSaving: boolean;
discordTokenLocked: boolean;
discordConfigStatus: string | null;
slackForm: SlackForm;
slackSaving: boolean;
slackTokenLocked: boolean;
slackAppTokenLocked: boolean;
slackConfigStatus: string | null;
signalForm: SignalForm;
signalSaving: boolean;
signalConfigStatus: string | null;
imessageForm: IMessageForm;
imessageSaving: boolean;
imessageConfigStatus: string | null;
configSnapshot: ConfigSnapshot | null;
};

View File

@ -45,14 +45,14 @@ describe("chat focus mode", () => {
await app.updateComplete; await app.updateComplete;
expect(shell?.classList.contains("shell--chat-focus")).toBe(true); expect(shell?.classList.contains("shell--chat-focus")).toBe(true);
const link = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/connections"]'); const link = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/channels"]');
expect(link).not.toBeNull(); expect(link).not.toBeNull();
link?.dispatchEvent( link?.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }), new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
); );
await app.updateComplete; await app.updateComplete;
expect(app.tab).toBe("connections"); expect(app.tab).toBe("channels");
expect(shell?.classList.contains("shell--chat-focus")).toBe(false); expect(shell?.classList.contains("shell--chat-focus")).toBe(false);
const chatLink = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/chat"]'); const chatLink = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/chat"]');

View File

@ -76,7 +76,7 @@ describe("control UI routing", () => {
await app.updateComplete; await app.updateComplete;
const link = app.querySelector<HTMLAnchorElement>( const link = app.querySelector<HTMLAnchorElement>(
'a.nav-item[href="/connections"]', 'a.nav-item[href="/channels"]',
); );
expect(link).not.toBeNull(); expect(link).not.toBeNull();
link?.dispatchEvent( link?.dispatchEvent(
@ -84,8 +84,8 @@ describe("control UI routing", () => {
); );
await app.updateComplete; await app.updateComplete;
expect(app.tab).toBe("connections"); expect(app.tab).toBe("channels");
expect(window.location.pathname).toBe("/connections"); expect(window.location.pathname).toBe("/channels");
}); });
it("keeps chat and nav usable on narrow viewports", async () => { it("keeps chat and nav usable on narrow viewports", async () => {

View File

@ -29,7 +29,7 @@ describe("iconForTab", () => {
it("returns stable icons for known tabs", () => { it("returns stable icons for known tabs", () => {
expect(iconForTab("chat")).toBe("💬"); expect(iconForTab("chat")).toBe("💬");
expect(iconForTab("overview")).toBe("📊"); expect(iconForTab("overview")).toBe("📊");
expect(iconForTab("connections")).toBe("🔗"); expect(iconForTab("channels")).toBe("🔗");
expect(iconForTab("instances")).toBe("📡"); expect(iconForTab("instances")).toBe("📡");
expect(iconForTab("sessions")).toBe("📄"); expect(iconForTab("sessions")).toBe("📄");
expect(iconForTab("cron")).toBe("⏰"); expect(iconForTab("cron")).toBe("⏰");

View File

@ -2,7 +2,7 @@ export const TAB_GROUPS = [
{ label: "Chat", tabs: ["chat"] }, { label: "Chat", tabs: ["chat"] },
{ {
label: "Control", label: "Control",
tabs: ["overview", "connections", "instances", "sessions", "cron"], tabs: ["overview", "channels", "instances", "sessions", "cron"],
}, },
{ label: "Agent", tabs: ["skills", "nodes"] }, { label: "Agent", tabs: ["skills", "nodes"] },
{ label: "Settings", tabs: ["config", "debug", "logs"] }, { label: "Settings", tabs: ["config", "debug", "logs"] },
@ -10,7 +10,7 @@ export const TAB_GROUPS = [
export type Tab = export type Tab =
| "overview" | "overview"
| "connections" | "channels"
| "instances" | "instances"
| "sessions" | "sessions"
| "cron" | "cron"
@ -23,7 +23,7 @@ export type Tab =
const TAB_PATHS: Record<Tab, string> = { const TAB_PATHS: Record<Tab, string> = {
overview: "/overview", overview: "/overview",
connections: "/connections", channels: "/channels",
instances: "/instances", instances: "/instances",
sessions: "/sessions", sessions: "/sessions",
cron: "/cron", cron: "/cron",
@ -104,7 +104,7 @@ export function iconForTab(tab: Tab): string {
return "💬"; return "💬";
case "overview": case "overview":
return "📊"; return "📊";
case "connections": case "channels":
return "🔗"; return "🔗";
case "instances": case "instances":
return "📡"; return "📡";
@ -131,8 +131,8 @@ export function titleForTab(tab: Tab) {
switch (tab) { switch (tab) {
case "overview": case "overview":
return "Overview"; return "Overview";
case "connections": case "channels":
return "Connections"; return "Channels";
case "instances": case "instances":
return "Instances"; return "Instances";
case "sessions": case "sessions":
@ -160,8 +160,8 @@ export function subtitleForTab(tab: Tab) {
switch (tab) { switch (tab) {
case "overview": case "overview":
return "Gateway status, entry points, and a fast health read."; return "Gateway status, entry points, and a fast health read.";
case "connections": case "channels":
return "Link channels and keep transport settings in sync."; return "Manage channels and settings.";
case "instances": case "instances":
return "Presence beacons from connected clients and nodes."; return "Presence beacons from connected clients and nodes.";
case "sessions": case "sessions":

View File

@ -1,159 +1,9 @@
export type TelegramForm = {
token: string;
requireMention: boolean;
groupsWildcardEnabled: boolean;
allowFrom: string;
proxy: string;
webhookUrl: string;
webhookSecret: string;
webhookPath: string;
};
export type ChatQueueItem = { export type ChatQueueItem = {
id: string; id: string;
text: string; text: string;
createdAt: number; createdAt: number;
}; };
export type DiscordForm = {
enabled: boolean;
token: string;
dmEnabled: boolean;
allowFrom: string;
groupEnabled: boolean;
groupChannels: string;
mediaMaxMb: string;
historyLimit: string;
textChunkLimit: string;
replyToMode: "off" | "first" | "all";
guilds: DiscordGuildForm[];
actions: DiscordActionForm;
slashEnabled: boolean;
slashName: string;
slashSessionPrefix: string;
slashEphemeral: boolean;
};
export type DiscordGuildForm = {
key: string;
slug: string;
requireMention: boolean;
reactionNotifications: "off" | "own" | "all" | "allowlist";
users: string;
channels: DiscordGuildChannelForm[];
};
export type DiscordGuildChannelForm = {
key: string;
allow: boolean;
requireMention: boolean;
};
export type DiscordActionForm = {
reactions: boolean;
stickers: boolean;
polls: boolean;
permissions: boolean;
messages: boolean;
threads: boolean;
pins: boolean;
search: boolean;
memberInfo: boolean;
roleInfo: boolean;
channelInfo: boolean;
voiceStatus: boolean;
events: boolean;
roles: boolean;
moderation: boolean;
};
export type SlackChannelForm = {
key: string;
allow: boolean;
requireMention: boolean;
};
export type SlackActionForm = {
reactions: boolean;
messages: boolean;
pins: boolean;
memberInfo: boolean;
emojiList: boolean;
};
export type SlackForm = {
enabled: boolean;
botToken: string;
appToken: string;
dmEnabled: boolean;
allowFrom: string;
groupEnabled: boolean;
groupChannels: string;
mediaMaxMb: string;
textChunkLimit: string;
reactionNotifications: "off" | "own" | "all" | "allowlist";
reactionAllowlist: string;
slashEnabled: boolean;
slashName: string;
slashSessionPrefix: string;
slashEphemeral: boolean;
actions: SlackActionForm;
channels: SlackChannelForm[];
};
export const defaultDiscordActions: DiscordActionForm = {
reactions: true,
stickers: true,
polls: true,
permissions: true,
messages: true,
threads: true,
pins: true,
search: true,
memberInfo: true,
roleInfo: true,
channelInfo: true,
voiceStatus: true,
events: true,
roles: false,
moderation: false,
};
export const defaultSlackActions: SlackActionForm = {
reactions: true,
messages: true,
pins: true,
memberInfo: true,
emojiList: true,
};
export type SignalForm = {
enabled: boolean;
account: string;
httpUrl: string;
httpHost: string;
httpPort: string;
cliPath: string;
autoStart: boolean;
receiveMode: "on-start" | "manual" | "";
ignoreAttachments: boolean;
ignoreStories: boolean;
sendReadReceipts: boolean;
allowFrom: string;
mediaMaxMb: string;
};
export type IMessageForm = {
enabled: boolean;
cliPath: string;
dbPath: string;
service: "auto" | "imessage" | "sms";
region: string;
allowFrom: string;
includeAttachments: boolean;
mediaMaxMb: string;
};
export type CronFormState = { export type CronFormState = {
name: string; name: string;
description: string; description: string;

View File

@ -0,0 +1,134 @@
import { html } from "lit";
import type { ConfigUiHints } from "../types";
import type { ChannelsProps } from "./channels.types";
import {
analyzeConfigSchema,
renderNode,
schemaType,
type JsonSchema,
} from "./config-form";
type ChannelConfigFormProps = {
channelId: string;
configValue: Record<string, unknown> | null;
schema: unknown | null;
uiHints: ConfigUiHints;
disabled: boolean;
onPatch: (path: Array<string | number>, value: unknown) => void;
};
function resolveSchemaNode(
schema: JsonSchema | null,
path: Array<string | number>,
): JsonSchema | null {
let current = schema;
for (const key of path) {
if (!current) return null;
const type = schemaType(current);
if (type === "object") {
const properties = current.properties ?? {};
if (typeof key === "string" && properties[key]) {
current = properties[key];
continue;
}
const additional = current.additionalProperties;
if (typeof key === "string" && additional && typeof additional === "object") {
current = additional as JsonSchema;
continue;
}
return null;
}
if (type === "array") {
if (typeof key !== "number") return null;
const items = Array.isArray(current.items) ? current.items[0] : current.items;
current = items ?? null;
continue;
}
return null;
}
return current;
}
function resolveChannelValue(
config: Record<string, unknown>,
channelId: string,
): Record<string, unknown> {
const channels = (config.channels ?? {}) as Record<string, unknown>;
const fromChannels = channels[channelId];
const fallback = config[channelId];
const resolved =
(fromChannels && typeof fromChannels === "object"
? (fromChannels as Record<string, unknown>)
: null) ??
(fallback && typeof fallback === "object"
? (fallback as Record<string, unknown>)
: null);
return resolved ?? {};
}
export function renderChannelConfigForm(props: ChannelConfigFormProps) {
const analysis = analyzeConfigSchema(props.schema);
const normalized = analysis.schema;
if (!normalized) {
return html`<div class="callout danger">Schema unavailable. Use Raw.</div>`;
}
const node = resolveSchemaNode(normalized, ["channels", props.channelId]);
if (!node) {
return html`<div class="callout danger">Channel config schema unavailable.</div>`;
}
const configValue = props.configValue ?? {};
const value = resolveChannelValue(configValue, props.channelId);
return html`
<div class="config-form">
${renderNode({
schema: node,
value,
path: ["channels", props.channelId],
hints: props.uiHints,
unsupported: new Set(analysis.unsupportedPaths),
disabled: props.disabled,
showLabel: false,
onPatch: props.onPatch,
})}
</div>
`;
}
export function renderChannelConfigSection(params: {
channelId: string;
props: ChannelsProps;
}) {
const { channelId, props } = params;
const disabled = props.configSaving || props.configSchemaLoading;
return html`
<div style="margin-top: 16px;">
${props.configSchemaLoading
? html`<div class="muted">Loading config schema…</div>`
: renderChannelConfigForm({
channelId,
configValue: props.configForm,
schema: props.configSchema,
uiHints: props.configUiHints,
disabled,
onPatch: props.onConfigPatch,
})}
<div class="row" style="margin-top: 12px;">
<button
class="btn primary"
?disabled=${disabled || !props.configFormDirty}
@click=${() => props.onConfigSave()}
>
${props.configSaving ? "Saving…" : "Save"}
</button>
<button
class="btn"
?disabled=${disabled}
@click=${() => props.onConfigReload()}
>
Reload
</button>
</div>
</div>
`;
}

View File

@ -0,0 +1,62 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { DiscordStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
export function renderDiscordCard(params: {
props: ChannelsProps;
discord?: DiscordStatus | null;
accountCountLabel: unknown;
}) {
const { props, discord, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">Discord</div>
<div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${discord?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${discord?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
</div>
</div>
${discord?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${discord.lastError}
</div>`
: nothing}
${discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "discord", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@ -0,0 +1,62 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { IMessageStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
export function renderIMessageCard(params: {
props: ChannelsProps;
imessage?: IMessageStatus | null;
accountCountLabel: unknown;
}) {
const { props, imessage, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">iMessage</div>
<div class="card-sub">macOS bridge status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${imessage?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${imessage?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
</div>
</div>
${imessage?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${imessage.lastError}
</div>`
: nothing}
${imessage?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
${imessage.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "imessage", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@ -0,0 +1,46 @@
import { html, nothing } from "lit";
import type { ChannelAccountSnapshot } from "../types";
import type { ChannelKey, ChannelsProps } from "./channels.types";
export function formatDuration(ms?: number | null) {
if (!ms && ms !== 0) return "n/a";
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.round(sec / 60);
if (min < 60) return `${min}m`;
const hr = Math.round(min / 60);
return `${hr}h`;
}
export function channelEnabled(key: ChannelKey, props: ChannelsProps) {
const snapshot = props.snapshot;
const channels = snapshot?.channels as Record<string, unknown> | null;
if (!snapshot || !channels) return false;
const channelStatus = channels[key] as Record<string, unknown> | undefined;
const configured = typeof channelStatus?.configured === "boolean" && channelStatus.configured;
const running = typeof channelStatus?.running === "boolean" && channelStatus.running;
const connected = typeof channelStatus?.connected === "boolean" && channelStatus.connected;
const accounts = snapshot.channelAccounts?.[key] ?? [];
const accountActive = accounts.some(
(account) => account.configured || account.running || account.connected,
);
return configured || running || connected || accountActive;
}
export function getChannelAccountCount(
key: ChannelKey,
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
): number {
return channelAccounts?.[key]?.length ?? 0;
}
export function renderChannelAccountCount(
key: ChannelKey,
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
) {
const count = getChannelAccountCount(key, channelAccounts);
if (count < 2) return nothing;
return html`<div class="account-count">Accounts (${count})</div>`;
}

View File

@ -0,0 +1,66 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { SignalStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
export function renderSignalCard(params: {
props: ChannelsProps;
signal?: SignalStatus | null;
accountCountLabel: unknown;
}) {
const { props, signal, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">Signal</div>
<div class="card-sub">signal-cli status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${signal?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${signal?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Base URL</span>
<span>${signal?.baseUrl ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
</div>
</div>
${signal?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${signal.lastError}
</div>`
: nothing}
${signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "signal", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@ -0,0 +1,62 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { SlackStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
export function renderSlackCard(params: {
props: ChannelsProps;
slack?: SlackStatus | null;
accountCountLabel: unknown;
}) {
const { props, slack, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">Slack</div>
<div class="card-sub">Socket mode status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${slack?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${slack?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
</div>
</div>
${slack?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${slack.lastError}
</div>`
: nothing}
${slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} ·
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "slack", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@ -0,0 +1,113 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, TelegramStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
export function renderTelegramCard(params: {
props: ChannelsProps;
telegram?: TelegramStatus;
telegramAccounts: ChannelAccountSnapshot[];
accountCountLabel: unknown;
}) {
const { props, telegram, telegramAccounts, accountCountLabel } = params;
const hasMultipleAccounts = telegramAccounts.length > 1;
const renderAccountCard = (account: ChannelAccountSnapshot) => {
const probe = account.probe as { bot?: { username?: string } } | undefined;
const botUsername = probe?.bot?.username;
const label = account.name || account.accountId;
return html`
<div class="account-card">
<div class="account-card-header">
<div class="account-card-title">
${botUsername ? `@${botUsername}` : label}
</div>
<div class="account-card-id">${account.accountId}</div>
</div>
<div class="status-list account-card-status">
<div>
<span class="label">Running</span>
<span>${account.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
</div>
${account.lastError
? html`
<div class="account-card-error">
${account.lastError}
</div>
`
: nothing}
</div>
</div>
`;
};
return html`
<div class="card">
<div class="card-title">Telegram</div>
<div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel}
${hasMultipleAccounts
? html`
<div class="account-card-list">
${telegramAccounts.map((account) => renderAccountCard(account))}
</div>
`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${telegram?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${telegram?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Mode</span>
<span>${telegram?.mode ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
</div>
</div>
`}
${telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${telegram.lastError}
</div>`
: nothing}
${telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "telegram", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

234
ui/src/ui/views/channels.ts Normal file
View File

@ -0,0 +1,234 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type {
ChannelAccountSnapshot,
ChannelsStatusSnapshot,
DiscordStatus,
IMessageStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
WhatsAppStatus,
} from "../types";
import type {
ChannelKey,
ChannelsChannelData,
ChannelsProps,
} from "./channels.types";
import { channelEnabled, renderChannelAccountCount } from "./channels.shared";
import { renderChannelConfigSection } from "./channels.config";
import { renderDiscordCard } from "./channels.discord";
import { renderIMessageCard } from "./channels.imessage";
import { renderSignalCard } from "./channels.signal";
import { renderSlackCard } from "./channels.slack";
import { renderTelegramCard } from "./channels.telegram";
import { renderWhatsAppCard } from "./channels.whatsapp";
export function renderChannels(props: ChannelsProps) {
const channels = props.snapshot?.channels as Record<string, unknown> | null;
const whatsapp = (channels?.whatsapp ?? undefined) as
| WhatsAppStatus
| undefined;
const telegram = (channels?.telegram ?? undefined) as
| TelegramStatus
| undefined;
const discord = (channels?.discord ?? null) as DiscordStatus | null;
const slack = (channels?.slack ?? null) as SlackStatus | null;
const signal = (channels?.signal ?? null) as SignalStatus | null;
const imessage = (channels?.imessage ?? null) as IMessageStatus | null;
const channelOrder = resolveChannelOrder(props.snapshot);
const orderedChannels = channelOrder
.map((key, index) => ({
key,
enabled: channelEnabled(key, props),
order: index,
}))
.sort((a, b) => {
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
return a.order - b.order;
});
return html`
<section class="grid grid-cols-2">
${orderedChannels.map((channel) =>
renderChannel(channel.key, props, {
whatsapp,
telegram,
discord,
slack,
signal,
imessage,
channelAccounts: props.snapshot?.channelAccounts ?? null,
}),
)}
</section>
<section class="card" style="margin-top: 18px;">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Channel health</div>
<div class="card-sub">Channel status snapshots from the gateway.</div>
</div>
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
</div>
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${props.lastError}
</div>`
: nothing}
<pre class="code-block" style="margin-top: 12px;">
${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
</pre>
</section>
`;
}
function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKey[] {
if (snapshot?.channelOrder?.length) {
return snapshot.channelOrder;
}
return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"];
}
function renderChannel(
key: ChannelKey,
props: ChannelsProps,
data: ChannelsChannelData,
) {
const accountCountLabel = renderChannelAccountCount(
key,
data.channelAccounts,
);
switch (key) {
case "whatsapp":
return renderWhatsAppCard({
props,
whatsapp: data.whatsapp,
accountCountLabel,
});
case "telegram":
return renderTelegramCard({
props,
telegram: data.telegram,
telegramAccounts: data.channelAccounts?.telegram ?? [],
accountCountLabel,
});
case "discord":
return renderDiscordCard({
props,
discord: data.discord,
accountCountLabel,
});
case "slack":
return renderSlackCard({
props,
slack: data.slack,
accountCountLabel,
});
case "signal":
return renderSignalCard({
props,
signal: data.signal,
accountCountLabel,
});
case "imessage":
return renderIMessageCard({
props,
imessage: data.imessage,
accountCountLabel,
});
default:
return renderGenericChannelCard(key, props, data.channelAccounts ?? {});
}
}
function renderGenericChannelCard(
key: ChannelKey,
props: ChannelsProps,
channelAccounts: Record<string, ChannelAccountSnapshot[]>,
) {
const label = props.snapshot?.channelLabels?.[key] ?? key;
const status = props.snapshot?.channels?.[key] as Record<string, unknown> | undefined;
const configured = typeof status?.configured === "boolean" ? status.configured : undefined;
const running = typeof status?.running === "boolean" ? status.running : undefined;
const connected = typeof status?.connected === "boolean" ? status.connected : undefined;
const lastError = typeof status?.lastError === "string" ? status.lastError : undefined;
const accounts = channelAccounts[key] ?? [];
const accountCountLabel = renderChannelAccountCount(key, channelAccounts);
return html`
<div class="card">
<div class="card-title">${label}</div>
<div class="card-sub">Channel status and configuration.</div>
${accountCountLabel}
${accounts.length > 0
? html`
<div class="account-card-list">
${accounts.map((account) => renderGenericAccount(account))}
</div>
`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${configured == null ? "n/a" : configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${running == null ? "n/a" : running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Connected</span>
<span>${connected == null ? "n/a" : connected ? "Yes" : "No"}</span>
</div>
</div>
`}
${lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${lastError}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: key, props })}
</div>
`;
}
function renderGenericAccount(account: ChannelAccountSnapshot) {
return html`
<div class="account-card">
<div class="account-card-header">
<div class="account-card-title">${account.name || account.accountId}</div>
<div class="account-card-id">${account.accountId}</div>
</div>
<div class="status-list account-card-status">
<div>
<span class="label">Running</span>
<span>${account.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Connected</span>
<span>${account.connected ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
</div>
${account.lastError
? html`
<div class="account-card-error">
${account.lastError}
</div>
`
: nothing}
</div>
</div>
`;
}

View File

@ -0,0 +1,48 @@
import type {
ChannelAccountSnapshot,
ChannelsStatusSnapshot,
ConfigUiHints,
DiscordStatus,
IMessageStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
WhatsAppStatus,
} from "../types";
export type ChannelKey = string;
export type ChannelsProps = {
connected: boolean;
loading: boolean;
snapshot: ChannelsStatusSnapshot | null;
lastError: string | null;
lastSuccessAt: number | null;
whatsappMessage: string | null;
whatsappQrDataUrl: string | null;
whatsappConnected: boolean | null;
whatsappBusy: boolean;
configSchema: unknown | null;
configSchemaLoading: boolean;
configForm: Record<string, unknown> | null;
configUiHints: ConfigUiHints;
configSaving: boolean;
configFormDirty: boolean;
onRefresh: (probe: boolean) => void;
onWhatsAppStart: (force: boolean) => void;
onWhatsAppWait: () => void;
onWhatsAppLogout: () => void;
onConfigPatch: (path: Array<string | number>, value: unknown) => void;
onConfigSave: () => void;
onConfigReload: () => void;
};
export type ChannelsChannelData = {
whatsapp?: WhatsAppStatus;
telegram?: TelegramStatus;
discord?: DiscordStatus | null;
slack?: SlackStatus | null;
signal?: SignalStatus | null;
imessage?: IMessageStatus | null;
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null;
};

View File

@ -2,11 +2,12 @@ import { html, nothing } from "lit";
import { formatAgo } from "../format"; import { formatAgo } from "../format";
import type { WhatsAppStatus } from "../types"; import type { WhatsAppStatus } from "../types";
import type { ConnectionsProps } from "./connections.types"; import type { ChannelsProps } from "./channels.types";
import { formatDuration } from "./connections.shared"; import { renderChannelConfigSection } from "./channels.config";
import { formatDuration } from "./channels.shared";
export function renderWhatsAppCard(params: { export function renderWhatsAppCard(params: {
props: ConnectionsProps; props: ChannelsProps;
whatsapp?: WhatsAppStatus; whatsapp?: WhatsAppStatus;
accountCountLabel: unknown; accountCountLabel: unknown;
}) { }) {
@ -110,6 +111,8 @@ export function renderWhatsAppCard(params: {
Refresh Refresh
</button> </button>
</div> </div>
${renderChannelConfigSection({ channelId: "whatsapp", props })}
</div> </div>
`; `;
} }

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

View File

@ -3,5 +3,6 @@ export {
analyzeConfigSchema, analyzeConfigSchema,
type ConfigSchemaAnalysis, type ConfigSchemaAnalysis,
} from "./config-form.analyze"; } from "./config-form.analyze";
export type { JsonSchema } from "./config-form.shared"; export { renderNode } from "./config-form.node";
export { schemaType, type JsonSchema } from "./config-form.shared";

View File

@ -1,20 +1,7 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import {
MOONSHOT_KIMI_K2_CONTEXT_WINDOW,
MOONSHOT_KIMI_K2_COST,
MOONSHOT_KIMI_K2_DEFAULT_ID,
MOONSHOT_KIMI_K2_INPUT,
MOONSHOT_KIMI_K2_MAX_TOKENS,
MOONSHOT_KIMI_K2_MODELS,
} from "../data/moonshot-kimi-k2";
import type { ConfigUiHints } from "../types"; import type { ConfigUiHints } from "../types";
import { analyzeConfigSchema, renderConfigForm } from "./config-form"; import { analyzeConfigSchema, renderConfigForm } from "./config-form";
type ConfigPatch = {
path: Array<string | number>;
value: unknown;
};
export type ConfigProps = { export type ConfigProps = {
raw: string; raw: string;
valid: boolean | null; valid: boolean | null;
@ -38,287 +25,6 @@ export type ConfigProps = {
onUpdate: () => void; onUpdate: () => void;
}; };
function cloneConfigObject<T>(value: T): T {
if (typeof structuredClone === "function") return structuredClone(value);
return JSON.parse(JSON.stringify(value)) as T;
}
function tryParseJsonObject(raw: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return null;
} catch {
return null;
}
}
function setPathValue(
obj: Record<string, unknown> | unknown[],
path: Array<string | number>,
value: unknown,
) {
if (path.length === 0) return;
let current: Record<string, unknown> | unknown[] = obj;
for (let i = 0; i < path.length - 1; i += 1) {
const key = path[i];
const nextKey = path[i + 1];
if (typeof key === "number") {
if (!Array.isArray(current)) return;
if (current[key] == null) {
current[key] =
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
}
current = current[key] as Record<string, unknown> | unknown[];
} else {
if (typeof current !== "object" || current == null) return;
const record = current as Record<string, unknown>;
if (record[key] == null) {
record[key] =
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
}
current = record[key] as Record<string, unknown> | unknown[];
}
}
const lastKey = path[path.length - 1];
if (typeof lastKey === "number") {
if (Array.isArray(current)) current[lastKey] = value;
return;
}
if (typeof current === "object" && current != null) {
(current as Record<string, unknown>)[lastKey] = value;
}
}
function getPathValue(
obj: unknown,
path: Array<string | number>,
): unknown | undefined {
let current: unknown = obj;
for (const key of path) {
if (typeof key === "number") {
if (!Array.isArray(current)) return undefined;
current = current[key];
} else {
if (!current || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[key];
}
}
return current;
}
function buildModelPresetPatches(base: Record<string, unknown>): Array<{
id: "minimax" | "zai" | "moonshot";
title: string;
description: string;
patches: ConfigPatch[];
}> {
const setPrimary = (modelRef: string) => ({
path: ["agents", "defaults", "model", "primary"],
value: modelRef,
});
const safeAlias = (modelRef: string, alias: string): ConfigPatch | null => {
const existingAlias = getPathValue(base, [
"agents",
"defaults",
"models",
modelRef,
"alias",
]);
if (typeof existingAlias === "string" && existingAlias.trim().length > 0) {
return null;
}
return {
path: ["agents", "defaults", "models", modelRef, "alias"],
value: alias,
};
};
const minimaxModelsPath = ["models", "providers", "minimax", "models"] satisfies Array<
string | number
>;
const moonshotModelsPath = [
"models",
"providers",
"moonshot",
"models",
] satisfies Array<string | number>;
const hasNonEmptyString = (value: unknown) =>
typeof value === "string" && value.trim().length > 0;
const envMinimax = getPathValue(base, ["env", "MINIMAX_API_KEY"]);
const envZai = getPathValue(base, ["env", "ZAI_API_KEY"]);
const envMoonshot = getPathValue(base, ["env", "MOONSHOT_API_KEY"]);
const minimaxHasModels = Array.isArray(getPathValue(base, minimaxModelsPath));
const moonshotHasModels = Array.isArray(getPathValue(base, moonshotModelsPath));
const minimaxProviderBaseUrl = getPathValue(base, [
"models",
"providers",
"minimax",
"baseUrl",
]);
const minimaxProviderApiKey = getPathValue(base, [
"models",
"providers",
"minimax",
"apiKey",
]);
const minimaxProviderApi = getPathValue(base, [
"models",
"providers",
"minimax",
"api",
]);
const moonshotProviderBaseUrl = getPathValue(base, [
"models",
"providers",
"moonshot",
"baseUrl",
]);
const moonshotProviderApiKey = getPathValue(base, [
"models",
"providers",
"moonshot",
"apiKey",
]);
const moonshotProviderApi = getPathValue(base, [
"models",
"providers",
"moonshot",
"api",
]);
const modelsMode = getPathValue(base, ["models", "mode"]);
const minimax: ConfigPatch[] = [];
if (!hasNonEmptyString(envMinimax)) {
minimax.push({ path: ["env", "MINIMAX_API_KEY"], value: "sk-..." });
}
if (modelsMode == null) {
minimax.push({ path: ["models", "mode"], value: "merge" });
}
// Intentional: enforce the preferred MiniMax endpoint/mode.
if (minimaxProviderBaseUrl !== "https://api.minimax.io/anthropic") {
minimax.push({
path: ["models", "providers", "minimax", "baseUrl"],
value: "https://api.minimax.io/anthropic",
});
}
if (!hasNonEmptyString(minimaxProviderApiKey)) {
minimax.push({
path: ["models", "providers", "minimax", "apiKey"],
value: "${MINIMAX_API_KEY}",
});
}
if (minimaxProviderApi !== "anthropic-messages") {
minimax.push({
path: ["models", "providers", "minimax", "api"],
value: "anthropic-messages",
});
}
if (!minimaxHasModels) {
minimax.push({
path: minimaxModelsPath as Array<string | number>,
value: [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
contextWindow: 200000,
maxTokens: 8192,
},
],
});
}
minimax.push(setPrimary("minimax/MiniMax-M2.1"));
const minimaxAlias = safeAlias("minimax/MiniMax-M2.1", "Minimax");
if (minimaxAlias) minimax.push(minimaxAlias);
const zai: ConfigPatch[] = [];
if (!hasNonEmptyString(envZai)) {
zai.push({ path: ["env", "ZAI_API_KEY"], value: "sk-..." });
}
zai.push(setPrimary("zai/glm-4.7"));
const zaiAlias = safeAlias("zai/glm-4.7", "GLM 4.7");
if (zaiAlias) zai.push(zaiAlias);
const moonshot: ConfigPatch[] = [];
if (!hasNonEmptyString(envMoonshot)) {
moonshot.push({ path: ["env", "MOONSHOT_API_KEY"], value: "sk-..." });
}
if (modelsMode == null) {
moonshot.push({ path: ["models", "mode"], value: "merge" });
}
if (!hasNonEmptyString(moonshotProviderBaseUrl)) {
moonshot.push({
path: ["models", "providers", "moonshot", "baseUrl"],
value: "https://api.moonshot.ai/v1",
});
}
if (!hasNonEmptyString(moonshotProviderApiKey)) {
moonshot.push({
path: ["models", "providers", "moonshot", "apiKey"],
value: "${MOONSHOT_API_KEY}",
});
}
if (!hasNonEmptyString(moonshotProviderApi)) {
moonshot.push({
path: ["models", "providers", "moonshot", "api"],
value: "openai-completions",
});
}
const moonshotModelDefinitions = MOONSHOT_KIMI_K2_MODELS.map((model) => ({
id: model.id,
name: model.name,
reasoning: model.reasoning,
input: [...MOONSHOT_KIMI_K2_INPUT],
cost: { ...MOONSHOT_KIMI_K2_COST },
contextWindow: MOONSHOT_KIMI_K2_CONTEXT_WINDOW,
maxTokens: MOONSHOT_KIMI_K2_MAX_TOKENS,
}));
if (!moonshotHasModels) {
moonshot.push({
path: moonshotModelsPath as Array<string | number>,
value: moonshotModelDefinitions,
});
}
moonshot.push(setPrimary(`moonshot/${MOONSHOT_KIMI_K2_DEFAULT_ID}`));
for (const model of MOONSHOT_KIMI_K2_MODELS) {
const moonshotAlias = safeAlias(`moonshot/${model.id}`, model.alias);
if (moonshotAlias) moonshot.push(moonshotAlias);
}
return [
{
id: "minimax",
title: "MiniMax M2.1 (Anthropic)",
description:
"Adds provider config for MiniMaxs /anthropic endpoint and sets it as the default model.",
patches: minimax,
},
{
id: "zai",
title: "GLM 4.7 (Z.AI)",
description: "Adds ZAI_API_KEY placeholder + sets default model to zai/glm-4.7.",
patches: zai,
},
{
id: "moonshot",
title: "Kimi (Moonshot)",
description:
"Adds Moonshot provider config + sets default model to kimi-k2-0905-preview (includes Kimi K2 turbo/thinking variants).",
patches: moonshot,
},
];
}
export function renderConfig(props: ConfigProps) { export function renderConfig(props: ConfigProps) {
const validity = const validity =
props.valid == null ? "unknown" : props.valid ? "valid" : "invalid"; props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
@ -339,25 +45,6 @@ export function renderConfig(props: ConfigProps) {
(props.formMode === "raw" ? true : canSaveForm); (props.formMode === "raw" ? true : canSaveForm);
const canUpdate = props.connected && !props.applying && !props.updating; const canUpdate = props.connected && !props.applying && !props.updating;
const applyPreset = (patches: ConfigPatch[]) => {
const base =
props.formValue ??
tryParseJsonObject(props.raw) ??
({} as Record<string, unknown>);
const next = cloneConfigObject(base);
for (const patch of patches) {
setPathValue(next, patch.path, patch.value);
}
props.onRawChange(`${JSON.stringify(next, null, 2).trimEnd()}\n`);
for (const patch of patches) props.onFormPatch(patch.path, patch.value);
};
const presetBase =
props.formValue ??
tryParseJsonObject(props.raw) ??
({} as Record<string, unknown>);
const modelPresets = buildModelPresetPatches(presetBase);
return html` return html`
<section class="card"> <section class="card">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
@ -414,31 +101,6 @@ export function renderConfig(props: ConfigProps) {
comes back. comes back.
</div> </div>
<div class="callout" style="margin-top: 12px;">
<div style="font-weight: 600;">Model presets</div>
<div class="muted" style="margin-top: 6px;">
One-click inserts for MiniMax, GLM 4.7 (Z.AI), and Kimi (Moonshot). Keeps
existing API keys and per-model params when present.
</div>
<div class="row" style="margin-top: 10px; flex-wrap: wrap;">
${modelPresets.map(
(preset) => html`
<button
class="btn"
?disabled=${props.loading || props.saving || !props.connected}
title=${preset.description}
@click=${() => applyPreset(preset.patches)}
>
${preset.title}
</button>
`,
)}
</div>
<div class="muted" style="margin-top: 8px;">
Tip: use <span class="mono">/model</span> to switch models without editing
config.
</div>
</div>
${props.formMode === "form" ${props.formMode === "form"
? html`<div style="margin-top: 12px;"> ? html`<div style="margin-top: 12px;">

View File

@ -1,28 +0,0 @@
import type { DiscordActionForm, SlackActionForm } from "../ui-types";
export const discordActionOptions = [
{ key: "reactions", label: "Reactions" },
{ key: "stickers", label: "Stickers" },
{ key: "polls", label: "Polls" },
{ key: "permissions", label: "Permissions" },
{ key: "messages", label: "Messages" },
{ key: "threads", label: "Threads" },
{ key: "pins", label: "Pins" },
{ key: "search", label: "Search" },
{ key: "memberInfo", label: "Member info" },
{ key: "roleInfo", label: "Role info" },
{ key: "channelInfo", label: "Channel info" },
{ key: "voiceStatus", label: "Voice status" },
{ key: "events", label: "Events" },
{ key: "roles", label: "Role changes" },
{ key: "moderation", label: "Moderation" },
] satisfies Array<{ key: keyof DiscordActionForm; label: string }>;
export const slackActionOptions = [
{ key: "reactions", label: "Reactions" },
{ key: "messages", label: "Messages" },
{ key: "pins", label: "Pins" },
{ key: "memberInfo", label: "Member info" },
{ key: "emojiList", label: "Emoji list" },
] satisfies Array<{ key: keyof SlackActionForm; label: string }>;

View File

@ -1,31 +0,0 @@
import { html } from "lit";
import type { ConnectionsProps } from "./connections.types";
import { discordActionOptions } from "./connections.action-options";
export function renderDiscordActionsSection(props: ConnectionsProps) {
return html`
<div class="card-sub" style="margin-top: 16px;">Tool actions</div>
<div class="form-grid" style="margin-top: 8px;">
${discordActionOptions.map(
(action) => html`<label class="field">
<span>${action.label}</span>
<select
.value=${props.discordForm.actions[action.key] ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
actions: {
...props.discordForm.actions,
[action.key]: (e.target as HTMLSelectElement).value === "yes",
},
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>`,
)}
</div>
`;
}

View File

@ -1,262 +0,0 @@
import { html, nothing } from "lit";
import type { ConnectionsProps } from "./connections.types";
export function renderDiscordGuildsEditor(props: ConnectionsProps) {
return html`
<div class="field full">
<span>Guilds</span>
<div class="card-sub">
Add each guild (id or slug) and optional channel rules. Empty channel
entries still allow that channel.
</div>
<div class="list">
${props.discordForm.guilds.map(
(guild, guildIndex) => html`
<div class="list-item">
<div class="list-main">
<div class="form-grid">
<label class="field">
<span>Guild id / slug</span>
<input
.value=${guild.key}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
key: (e.target as HTMLInputElement).value,
};
props.onDiscordChange({ guilds: next });
}}
/>
</label>
<label class="field">
<span>Slug</span>
<input
.value=${guild.slug}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
slug: (e.target as HTMLInputElement).value,
};
props.onDiscordChange({ guilds: next });
}}
/>
</label>
<label class="field">
<span>Require mention</span>
<select
.value=${guild.requireMention ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
requireMention:
(e.target as HTMLSelectElement).value === "yes",
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Reaction notifications</span>
<select
.value=${guild.reactionNotifications}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
reactionNotifications: (e.target as HTMLSelectElement)
.value as "off" | "own" | "all" | "allowlist",
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="off">Off</option>
<option value="own">Own</option>
<option value="all">All</option>
<option value="allowlist">Allowlist</option>
</select>
</label>
<label class="field">
<span>Users allowlist</span>
<input
.value=${guild.users}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
users: (e.target as HTMLInputElement).value,
};
props.onDiscordChange({ guilds: next });
}}
placeholder="123456789, username#1234"
/>
</label>
</div>
${guild.channels.length
? html`
<div class="form-grid" style="margin-top: 8px;">
${guild.channels.map(
(channel, channelIndex) => html`
<label class="field">
<span>Channel id / slug</span>
<input
.value=${channel.key}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels[channelIndex] = {
...channels[channelIndex],
key: (e.target as HTMLInputElement).value,
};
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
/>
</label>
<label class="field">
<span>Allow</span>
<select
.value=${channel.allow ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels[channelIndex] = {
...channels[channelIndex],
allow:
(e.target as HTMLSelectElement).value ===
"yes",
};
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Require mention</span>
<select
.value=${channel.requireMention ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels[channelIndex] = {
...channels[channelIndex],
requireMention:
(e.target as HTMLSelectElement).value ===
"yes",
};
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>&nbsp;</span>
<button
class="btn"
@click=${() => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels.splice(channelIndex, 1);
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
Remove
</button>
</label>
`,
)}
</div>
`
: nothing}
</div>
<div class="list-meta">
<span>Channels</span>
<button
class="btn"
@click=${() => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
{ key: "", allow: true, requireMention: false },
];
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
Add channel
</button>
<button
class="btn danger"
@click=${() => {
const next = [...props.discordForm.guilds];
next.splice(guildIndex, 1);
props.onDiscordChange({ guilds: next });
}}
>
Remove guild
</button>
</div>
</div>
`,
)}
</div>
<button
class="btn"
style="margin-top: 8px;"
@click=${() =>
props.onDiscordChange({
guilds: [
...props.discordForm.guilds,
{
key: "",
slug: "",
requireMention: false,
reactionNotifications: "own",
users: "",
channels: [],
},
],
})}
>
Add guild
</button>
</div>
`;
}

View File

@ -1,261 +0,0 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { DiscordStatus } from "../types";
import type { ConnectionsProps } from "./connections.types";
import { renderDiscordActionsSection } from "./connections.discord.actions";
import { renderDiscordGuildsEditor } from "./connections.discord.guilds";
export function renderDiscordCard(params: {
props: ConnectionsProps;
discord: DiscordStatus | null;
accountCountLabel: unknown;
}) {
const { props, discord, accountCountLabel } = params;
const botName = discord?.probe?.bot?.username;
return html`
<div class="card">
<div class="card-title">Discord</div>
<div class="card-sub">Bot connection and probe status.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${discord?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${discord?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Bot</span>
<span>${botName ? `@${botName}` : "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
</div>
</div>
${discord?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${discord.lastError}
</div>`
: nothing}
${discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Enabled</span>
<select
.value=${props.discordForm.enabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
enabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Bot token</span>
<input
type="password"
.value=${props.discordForm.token}
?disabled=${props.discordTokenLocked}
@input=${(e: Event) =>
props.onDiscordChange({
token: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
<span>Allow DMs from</span>
<input
.value=${props.discordForm.allowFrom}
@input=${(e: Event) =>
props.onDiscordChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="123456789, username#1234"
/>
</label>
<label class="field">
<span>DMs enabled</span>
<select
.value=${props.discordForm.dmEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
dmEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Group DMs</span>
<select
.value=${props.discordForm.groupEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
groupEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Group channels</span>
<input
.value=${props.discordForm.groupChannels}
@input=${(e: Event) =>
props.onDiscordChange({
groupChannels: (e.target as HTMLInputElement).value,
})}
placeholder="channelId1, channelId2"
/>
</label>
<label class="field">
<span>Media max MB</span>
<input
.value=${props.discordForm.mediaMaxMb}
@input=${(e: Event) =>
props.onDiscordChange({
mediaMaxMb: (e.target as HTMLInputElement).value,
})}
placeholder="8"
/>
</label>
<label class="field">
<span>History limit</span>
<input
.value=${props.discordForm.historyLimit}
@input=${(e: Event) =>
props.onDiscordChange({
historyLimit: (e.target as HTMLInputElement).value,
})}
placeholder="20"
/>
</label>
<label class="field">
<span>Text chunk limit</span>
<input
.value=${props.discordForm.textChunkLimit}
@input=${(e: Event) =>
props.onDiscordChange({
textChunkLimit: (e.target as HTMLInputElement).value,
})}
placeholder="2000"
/>
</label>
<label class="field">
<span>Reply to mode</span>
<select
.value=${props.discordForm.replyToMode}
@change=${(e: Event) =>
props.onDiscordChange({
replyToMode: (e.target as HTMLSelectElement).value as
| "off"
| "first"
| "all",
})}
>
<option value="off">Off</option>
<option value="first">First</option>
<option value="all">All</option>
</select>
</label>
${renderDiscordGuildsEditor(props)}
<label class="field">
<span>Slash command</span>
<select
.value=${props.discordForm.slashEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
slashEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Slash name</span>
<input
.value=${props.discordForm.slashName}
@input=${(e: Event) =>
props.onDiscordChange({
slashName: (e.target as HTMLInputElement).value,
})}
placeholder="clawd"
/>
</label>
<label class="field">
<span>Slash session prefix</span>
<input
.value=${props.discordForm.slashSessionPrefix}
@input=${(e: Event) =>
props.onDiscordChange({
slashSessionPrefix: (e.target as HTMLInputElement).value,
})}
placeholder="discord:slash"
/>
</label>
<label class="field">
<span>Slash ephemeral</span>
<select
.value=${props.discordForm.slashEphemeral ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
slashEphemeral: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
</div>
${renderDiscordActionsSection(props)}
${props.discordTokenLocked
? html`<div class="callout" style="margin-top: 12px;">
DISCORD_BOT_TOKEN is set in the environment. Config edits will not
override it.
</div>`
: nothing}
${props.discordStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.discordStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.discordSaving}
@click=${() => props.onDiscordSave()}
>
${props.discordSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>
</div>
`;
}

View File

@ -1,184 +0,0 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { IMessageStatus } from "../types";
import type { ConnectionsProps } from "./connections.types";
export function renderIMessageCard(params: {
props: ConnectionsProps;
imessage: IMessageStatus | null;
accountCountLabel: unknown;
}) {
const { props, imessage, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">iMessage</div>
<div class="card-sub">imsg CLI and database availability.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${imessage?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${imessage?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">CLI</span>
<span>${imessage?.cliPath ?? "n/a"}</span>
</div>
<div>
<span class="label">DB</span>
<span>${imessage?.dbPath ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>
${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}
</span>
</div>
<div>
<span class="label">Last probe</span>
<span>
${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}
</span>
</div>
</div>
${imessage?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${imessage.lastError}
</div>`
: nothing}
${imessage?.probe && !imessage.probe.ok
? html`<div class="callout" style="margin-top: 12px;">
Probe failed · ${imessage.probe.error ?? "unknown error"}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Enabled</span>
<select
.value=${props.imessageForm.enabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onIMessageChange({
enabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>CLI path</span>
<input
.value=${props.imessageForm.cliPath}
@input=${(e: Event) =>
props.onIMessageChange({
cliPath: (e.target as HTMLInputElement).value,
})}
placeholder="imsg"
/>
</label>
<label class="field">
<span>DB path</span>
<input
.value=${props.imessageForm.dbPath}
@input=${(e: Event) =>
props.onIMessageChange({
dbPath: (e.target as HTMLInputElement).value,
})}
placeholder="~/Library/Messages/chat.db"
/>
</label>
<label class="field">
<span>Service</span>
<select
.value=${props.imessageForm.service}
@change=${(e: Event) =>
props.onIMessageChange({
service: (e.target as HTMLSelectElement).value as
| "auto"
| "imessage"
| "sms",
})}
>
<option value="auto">Auto</option>
<option value="imessage">iMessage</option>
<option value="sms">SMS</option>
</select>
</label>
<label class="field">
<span>Region</span>
<input
.value=${props.imessageForm.region}
@input=${(e: Event) =>
props.onIMessageChange({
region: (e.target as HTMLInputElement).value,
})}
placeholder="US"
/>
</label>
<label class="field">
<span>Allow from</span>
<input
.value=${props.imessageForm.allowFrom}
@input=${(e: Event) =>
props.onIMessageChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="chat_id:101, +1555"
/>
</label>
<label class="field">
<span>Include attachments</span>
<select
.value=${props.imessageForm.includeAttachments ? "yes" : "no"}
@change=${(e: Event) =>
props.onIMessageChange({
includeAttachments:
(e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Media max MB</span>
<input
.value=${props.imessageForm.mediaMaxMb}
@input=${(e: Event) =>
props.onIMessageChange({
mediaMaxMb: (e.target as HTMLInputElement).value,
})}
placeholder="16"
/>
</label>
</div>
${props.imessageStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.imessageStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.imessageSaving}
@click=${() => props.onIMessageSave()}
>
${props.imessageSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>
</div>
`;
}

View File

@ -1,71 +0,0 @@
import { html, nothing } from "lit";
import type {
DiscordStatus,
IMessageStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
WhatsAppStatus,
} from "../types";
import type { ChannelAccountSnapshot } from "../types";
import type { ChannelKey, ConnectionsProps } from "./connections.types";
export function formatDuration(ms?: number | null) {
if (!ms && ms !== 0) return "n/a";
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.round(sec / 60);
if (min < 60) return `${min}m`;
const hr = Math.round(min / 60);
return `${hr}h`;
}
export function channelEnabled(key: ChannelKey, props: ConnectionsProps) {
const snapshot = props.snapshot;
const channels = snapshot?.channels as Record<string, unknown> | null;
if (!snapshot || !channels) return false;
const whatsapp = channels.whatsapp as WhatsAppStatus | undefined;
const telegram = channels.telegram as TelegramStatus | undefined;
const discord = (channels.discord ?? null) as DiscordStatus | null;
const slack = (channels.slack ?? null) as SlackStatus | null;
const signal = (channels.signal ?? null) as SignalStatus | null;
const imessage = (channels.imessage ?? null) as IMessageStatus | null;
switch (key) {
case "whatsapp":
return (
Boolean(whatsapp?.configured) ||
Boolean(whatsapp?.linked) ||
Boolean(whatsapp?.running)
);
case "telegram":
return Boolean(telegram?.configured) || Boolean(telegram?.running);
case "discord":
return Boolean(discord?.configured || discord?.running);
case "slack":
return Boolean(slack?.configured || slack?.running);
case "signal":
return Boolean(signal?.configured || signal?.running);
case "imessage":
return Boolean(imessage?.configured || imessage?.running);
default:
return false;
}
}
export function getChannelAccountCount(
key: ChannelKey,
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
): number {
return channelAccounts?.[key]?.length ?? 0;
}
export function renderChannelAccountCount(
key: ChannelKey,
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
) {
const count = getChannelAccountCount(key, channelAccounts);
if (count < 2) return nothing;
return html`<div class="account-count">Accounts (${count})</div>`;
}

View File

@ -1,237 +0,0 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { SignalStatus } from "../types";
import type { ConnectionsProps } from "./connections.types";
export function renderSignalCard(params: {
props: ConnectionsProps;
signal: SignalStatus | null;
accountCountLabel: unknown;
}) {
const { props, signal, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">Signal</div>
<div class="card-sub">REST daemon status and probe details.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${signal?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${signal?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Base URL</span>
<span>${signal?.baseUrl ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
</div>
</div>
${signal?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${signal.lastError}
</div>`
: nothing}
${signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Enabled</span>
<select
.value=${props.signalForm.enabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSignalChange({
enabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Account</span>
<input
.value=${props.signalForm.account}
@input=${(e: Event) =>
props.onSignalChange({
account: (e.target as HTMLInputElement).value,
})}
placeholder="+15551234567"
/>
</label>
<label class="field">
<span>HTTP URL</span>
<input
.value=${props.signalForm.httpUrl}
@input=${(e: Event) =>
props.onSignalChange({
httpUrl: (e.target as HTMLInputElement).value,
})}
placeholder="http://127.0.0.1:8080"
/>
</label>
<label class="field">
<span>HTTP host</span>
<input
.value=${props.signalForm.httpHost}
@input=${(e: Event) =>
props.onSignalChange({
httpHost: (e.target as HTMLInputElement).value,
})}
placeholder="127.0.0.1"
/>
</label>
<label class="field">
<span>HTTP port</span>
<input
.value=${props.signalForm.httpPort}
@input=${(e: Event) =>
props.onSignalChange({
httpPort: (e.target as HTMLInputElement).value,
})}
placeholder="8080"
/>
</label>
<label class="field">
<span>CLI path</span>
<input
.value=${props.signalForm.cliPath}
@input=${(e: Event) =>
props.onSignalChange({
cliPath: (e.target as HTMLInputElement).value,
})}
placeholder="signal-cli"
/>
</label>
<label class="field">
<span>Auto start</span>
<select
.value=${props.signalForm.autoStart ? "yes" : "no"}
@change=${(e: Event) =>
props.onSignalChange({
autoStart: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Receive mode</span>
<select
.value=${props.signalForm.receiveMode}
@change=${(e: Event) =>
props.onSignalChange({
receiveMode: (e.target as HTMLSelectElement).value as
| "on-start"
| "manual"
| "",
})}
>
<option value="">Default</option>
<option value="on-start">on-start</option>
<option value="manual">manual</option>
</select>
</label>
<label class="field">
<span>Ignore attachments</span>
<select
.value=${props.signalForm.ignoreAttachments ? "yes" : "no"}
@change=${(e: Event) =>
props.onSignalChange({
ignoreAttachments: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Ignore stories</span>
<select
.value=${props.signalForm.ignoreStories ? "yes" : "no"}
@change=${(e: Event) =>
props.onSignalChange({
ignoreStories: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Send read receipts</span>
<select
.value=${props.signalForm.sendReadReceipts ? "yes" : "no"}
@change=${(e: Event) =>
props.onSignalChange({
sendReadReceipts: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Allow from</span>
<input
.value=${props.signalForm.allowFrom}
@input=${(e: Event) =>
props.onSignalChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="12345, +1555"
/>
</label>
<label class="field">
<span>Media max MB</span>
<input
.value=${props.signalForm.mediaMaxMb}
@input=${(e: Event) =>
props.onSignalChange({
mediaMaxMb: (e.target as HTMLInputElement).value,
})}
placeholder="8"
/>
</label>
</div>
${props.signalStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.signalStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.signalSaving}
@click=${() => props.onSignalSave()}
>
${props.signalSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>
</div>
`;
}

View File

@ -1,391 +0,0 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { SlackStatus } from "../types";
import type { ConnectionsProps } from "./connections.types";
import { slackActionOptions } from "./connections.action-options";
export function renderSlackCard(params: {
props: ConnectionsProps;
slack: SlackStatus | null;
accountCountLabel: unknown;
}) {
const { props, slack, accountCountLabel } = params;
const botName = slack?.probe?.bot?.name;
const teamName = slack?.probe?.team?.name;
return html`
<div class="card">
<div class="card-title">Slack</div>
<div class="card-sub">Socket mode status and bot details.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${slack?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${slack?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Bot</span>
<span>${botName ? botName : "n/a"}</span>
</div>
<div>
<span class="label">Team</span>
<span>${teamName ? teamName : "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
</div>
</div>
${slack?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${slack.lastError}
</div>`
: nothing}
${slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} · ${slack.probe.status ?? ""}
${slack.probe.error ?? ""}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Enabled</span>
<select
.value=${props.slackForm.enabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
enabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Bot token</span>
<input
type="password"
.value=${props.slackForm.botToken}
?disabled=${props.slackTokenLocked}
@input=${(e: Event) =>
props.onSlackChange({
botToken: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
<span>App token</span>
<input
type="password"
.value=${props.slackForm.appToken}
?disabled=${props.slackAppTokenLocked}
@input=${(e: Event) =>
props.onSlackChange({
appToken: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
<span>DMs enabled</span>
<select
.value=${props.slackForm.dmEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
dmEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Allow DMs from</span>
<input
.value=${props.slackForm.allowFrom}
@input=${(e: Event) =>
props.onSlackChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="U123, U456, *"
/>
</label>
<label class="field">
<span>Group DMs enabled</span>
<select
.value=${props.slackForm.groupEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
groupEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Group DM channels</span>
<input
.value=${props.slackForm.groupChannels}
@input=${(e: Event) =>
props.onSlackChange({
groupChannels: (e.target as HTMLInputElement).value,
})}
placeholder="G123, #team"
/>
</label>
<label class="field">
<span>Reaction notifications</span>
<select
.value=${props.slackForm.reactionNotifications}
@change=${(e: Event) =>
props.onSlackChange({
reactionNotifications: (e.target as HTMLSelectElement)
.value as "off" | "own" | "all" | "allowlist",
})}
>
<option value="off">Off</option>
<option value="own">Own</option>
<option value="all">All</option>
<option value="allowlist">Allowlist</option>
</select>
</label>
<label class="field">
<span>Reaction allowlist</span>
<input
.value=${props.slackForm.reactionAllowlist}
@input=${(e: Event) =>
props.onSlackChange({
reactionAllowlist: (e.target as HTMLInputElement).value,
})}
placeholder="U123, U456"
/>
</label>
<label class="field">
<span>Text chunk limit</span>
<input
.value=${props.slackForm.textChunkLimit}
@input=${(e: Event) =>
props.onSlackChange({
textChunkLimit: (e.target as HTMLInputElement).value,
})}
placeholder="4000"
/>
</label>
<label class="field">
<span>Media max (MB)</span>
<input
.value=${props.slackForm.mediaMaxMb}
@input=${(e: Event) =>
props.onSlackChange({
mediaMaxMb: (e.target as HTMLInputElement).value,
})}
placeholder="20"
/>
</label>
</div>
<div class="card-sub" style="margin-top: 16px;">Slash command</div>
<div class="form-grid" style="margin-top: 8px;">
<label class="field">
<span>Slash enabled</span>
<select
.value=${props.slackForm.slashEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
slashEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Slash name</span>
<input
.value=${props.slackForm.slashName}
@input=${(e: Event) =>
props.onSlackChange({
slashName: (e.target as HTMLInputElement).value,
})}
placeholder="clawd"
/>
</label>
<label class="field">
<span>Slash session prefix</span>
<input
.value=${props.slackForm.slashSessionPrefix}
@input=${(e: Event) =>
props.onSlackChange({
slashSessionPrefix: (e.target as HTMLInputElement).value,
})}
placeholder="slack:slash"
/>
</label>
<label class="field">
<span>Slash ephemeral</span>
<select
.value=${props.slackForm.slashEphemeral ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
slashEphemeral: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
</div>
<div class="card-sub" style="margin-top: 16px;">Channels</div>
<div class="card-sub">Add channel ids or #names and optionally require mentions.</div>
<div class="list">
${props.slackForm.channels.map(
(channel, channelIndex) => html`
<div class="list-item">
<div class="list-main">
<div class="form-grid">
<label class="field">
<span>Channel id / name</span>
<input
.value=${channel.key}
@input=${(e: Event) => {
const next = [...props.slackForm.channels];
next[channelIndex] = {
...next[channelIndex],
key: (e.target as HTMLInputElement).value,
};
props.onSlackChange({ channels: next });
}}
/>
</label>
<label class="field">
<span>Allow</span>
<select
.value=${channel.allow ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.slackForm.channels];
next[channelIndex] = {
...next[channelIndex],
allow: (e.target as HTMLSelectElement).value === "yes",
};
props.onSlackChange({ channels: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Require mention</span>
<select
.value=${channel.requireMention ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.slackForm.channels];
next[channelIndex] = {
...next[channelIndex],
requireMention:
(e.target as HTMLSelectElement).value === "yes",
};
props.onSlackChange({ channels: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>&nbsp;</span>
<button
class="btn"
@click=${() => {
const next = [...props.slackForm.channels];
next.splice(channelIndex, 1);
props.onSlackChange({ channels: next });
}}
>
Remove
</button>
</label>
</div>
</div>
</div>
`,
)}
</div>
<button
class="btn"
style="margin-top: 8px;"
@click=${() =>
props.onSlackChange({
channels: [
...props.slackForm.channels,
{ key: "", allow: true, requireMention: false },
],
})}
>
Add channel
</button>
<div class="card-sub" style="margin-top: 16px;">Tool actions</div>
<div class="form-grid" style="margin-top: 8px;">
${slackActionOptions.map(
(action) => html`<label class="field">
<span>${action.label}</span>
<select
.value=${props.slackForm.actions[action.key] ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
actions: {
...props.slackForm.actions,
[action.key]: (e.target as HTMLSelectElement).value === "yes",
},
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>`,
)}
</div>
${props.slackTokenLocked || props.slackAppTokenLocked
? html`<div class="callout" style="margin-top: 12px;">
${props.slackTokenLocked ? "SLACK_BOT_TOKEN " : ""}
${props.slackAppTokenLocked ? "SLACK_APP_TOKEN " : ""} is set in the
environment. Config edits will not override it.
</div>`
: nothing}
${props.slackStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.slackStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.slackSaving}
@click=${() => props.onSlackSave()}
>
${props.slackSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>
</div>
`;
}

View File

@ -1,248 +0,0 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, TelegramStatus } from "../types";
import type { ConnectionsProps } from "./connections.types";
export function renderTelegramCard(params: {
props: ConnectionsProps;
telegram?: TelegramStatus;
telegramAccounts: ChannelAccountSnapshot[];
accountCountLabel: unknown;
}) {
const { props, telegram, telegramAccounts, accountCountLabel } = params;
const hasMultipleAccounts = telegramAccounts.length > 1;
const renderAccountCard = (account: ChannelAccountSnapshot) => {
const probe = account.probe as { bot?: { username?: string } } | undefined;
const botUsername = probe?.bot?.username;
const label = account.name || account.accountId;
return html`
<div class="account-card">
<div class="account-card-header">
<div class="account-card-title">
${botUsername ? `@${botUsername}` : label}
</div>
<div class="account-card-id">${account.accountId}</div>
</div>
<div class="status-list account-card-status">
<div>
<span class="label">Running</span>
<span>${account.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
</div>
${account.lastError
? html`
<div class="account-card-error">
${account.lastError}
</div>
`
: nothing}
</div>
</div>
`;
};
return html`
<div class="card">
<div class="card-title">Telegram</div>
<div class="card-sub">Bot token and delivery options.</div>
${accountCountLabel}
${hasMultipleAccounts
? html`
<div class="account-card-list">
${telegramAccounts.map((account) => renderAccountCard(account))}
</div>
`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${telegram?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${telegram?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Mode</span>
<span>${telegram?.mode ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
</div>
</div>
`}
${telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${telegram.lastError}
</div>`
: nothing}
${telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Bot token</span>
<input
type="password"
.value=${props.telegramForm.token}
?disabled=${props.telegramTokenLocked}
@input=${(e: Event) =>
props.onTelegramChange({
token: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
<span>Apply default group rules</span>
<select
.value=${props.telegramForm.groupsWildcardEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onTelegramChange({
groupsWildcardEnabled:
(e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="no">No</option>
<option value="yes">Yes (allow all groups)</option>
</select>
</label>
<label class="field">
<span>Require mention in groups</span>
<select
.value=${props.telegramForm.requireMention ? "yes" : "no"}
?disabled=${!props.telegramForm.groupsWildcardEnabled}
@change=${(e: Event) =>
props.onTelegramChange({
requireMention: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Allow from</span>
<input
.value=${props.telegramForm.allowFrom}
@input=${(e: Event) =>
props.onTelegramChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="123456789, @team, tg:123"
/>
</label>
<label class="field">
<span>Proxy</span>
<input
.value=${props.telegramForm.proxy}
@input=${(e: Event) =>
props.onTelegramChange({
proxy: (e.target as HTMLInputElement).value,
})}
placeholder="socks5://localhost:9050"
/>
</label>
<label class="field">
<span>Webhook URL</span>
<input
.value=${props.telegramForm.webhookUrl}
@input=${(e: Event) =>
props.onTelegramChange({
webhookUrl: (e.target as HTMLInputElement).value,
})}
placeholder="https://example.com/telegram-webhook"
/>
</label>
<label class="field">
<span>Webhook secret</span>
<input
.value=${props.telegramForm.webhookSecret}
@input=${(e: Event) =>
props.onTelegramChange({
webhookSecret: (e.target as HTMLInputElement).value,
})}
placeholder="secret"
/>
</label>
<label class="field">
<span>Webhook path</span>
<input
.value=${props.telegramForm.webhookPath}
@input=${(e: Event) =>
props.onTelegramChange({
webhookPath: (e.target as HTMLInputElement).value,
})}
placeholder="/telegram-webhook"
/>
</label>
</div>
<div class="callout" style="margin-top: 12px;">
Allow from supports numeric user IDs (recommended) or @usernames. DM the bot
to get your ID, or run /whoami.
</div>
${props.telegramTokenLocked
? html`<div class="callout" style="margin-top: 12px;">
TELEGRAM_BOT_TOKEN is set in the environment. Config edits will not override it.
</div>`
: nothing}
${props.telegramForm.groupsWildcardEnabled
? html`<div class="callout danger" style="margin-top: 12px;">
This writes telegram.groups["*"] and allows all groups. Remove it
if you only want specific groups.
<div class="row" style="margin-top: 8px;">
<button
class="btn"
@click=${() => props.onTelegramChange({ groupsWildcardEnabled: false })}
>
Remove wildcard
</button>
</div>
</div>`
: nothing}
${props.telegramStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.telegramStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.telegramSaving}
@click=${() => props.onTelegramSave()}
>
${props.telegramSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@ -1,141 +0,0 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type {
DiscordStatus,
IMessageStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
WhatsAppStatus,
} from "../types";
import type {
ChannelKey,
ConnectionsChannelData,
ConnectionsProps,
} from "./connections.types";
import { channelEnabled, renderChannelAccountCount } from "./connections.shared";
import { renderDiscordCard } from "./connections.discord";
import { renderIMessageCard } from "./connections.imessage";
import { renderSignalCard } from "./connections.signal";
import { renderSlackCard } from "./connections.slack";
import { renderTelegramCard } from "./connections.telegram";
import { renderWhatsAppCard } from "./connections.whatsapp";
export function renderConnections(props: ConnectionsProps) {
const channels = props.snapshot?.channels as Record<string, unknown> | null;
const whatsapp = (channels?.whatsapp ?? undefined) as
| WhatsAppStatus
| undefined;
const telegram = (channels?.telegram ?? undefined) as
| TelegramStatus
| undefined;
const discord = (channels?.discord ?? null) as DiscordStatus | null;
const slack = (channels?.slack ?? null) as SlackStatus | null;
const signal = (channels?.signal ?? null) as SignalStatus | null;
const imessage = (channels?.imessage ?? null) as IMessageStatus | null;
const channelOrder: ChannelKey[] = [
"whatsapp",
"telegram",
"discord",
"slack",
"signal",
"imessage",
];
const orderedChannels = channelOrder
.map((key, index) => ({
key,
enabled: channelEnabled(key, props),
order: index,
}))
.sort((a, b) => {
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
return a.order - b.order;
});
return html`
<section class="grid grid-cols-2">
${orderedChannels.map((channel) =>
renderChannel(channel.key, props, {
whatsapp,
telegram,
discord,
slack,
signal,
imessage,
channelAccounts: props.snapshot?.channelAccounts ?? null,
}),
)}
</section>
<section class="card" style="margin-top: 18px;">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Connection health</div>
<div class="card-sub">Channel status snapshots from the gateway.</div>
</div>
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
</div>
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${props.lastError}
</div>`
: nothing}
<pre class="code-block" style="margin-top: 12px;">
${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
</pre>
</section>
`;
}
function renderChannel(
key: ChannelKey,
props: ConnectionsProps,
data: ConnectionsChannelData,
) {
const accountCountLabel = renderChannelAccountCount(
key,
data.channelAccounts,
);
switch (key) {
case "whatsapp":
return renderWhatsAppCard({
props,
whatsapp: data.whatsapp,
accountCountLabel,
});
case "telegram":
return renderTelegramCard({
props,
telegram: data.telegram,
telegramAccounts: data.channelAccounts?.telegram ?? [],
accountCountLabel,
});
case "discord":
return renderDiscordCard({
props,
discord: data.discord,
accountCountLabel,
});
case "slack":
return renderSlackCard({
props,
slack: data.slack,
accountCountLabel,
});
case "signal":
return renderSignalCard({
props,
signal: data.signal,
accountCountLabel,
});
case "imessage":
return renderIMessageCard({
props,
imessage: data.imessage,
accountCountLabel,
});
default:
return nothing;
}
}

View File

@ -1,81 +0,0 @@
import type {
ChannelAccountSnapshot,
ChannelsStatusSnapshot,
DiscordStatus,
IMessageStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
WhatsAppStatus,
} from "../types";
import type {
DiscordForm,
IMessageForm,
SignalForm,
SlackForm,
TelegramForm,
} from "../ui-types";
export type ChannelKey =
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage";
export type ConnectionsProps = {
connected: boolean;
loading: boolean;
snapshot: ChannelsStatusSnapshot | null;
lastError: string | null;
lastSuccessAt: number | null;
whatsappMessage: string | null;
whatsappQrDataUrl: string | null;
whatsappConnected: boolean | null;
whatsappBusy: boolean;
telegramForm: TelegramForm;
telegramTokenLocked: boolean;
telegramSaving: boolean;
telegramStatus: string | null;
discordForm: DiscordForm;
discordTokenLocked: boolean;
discordSaving: boolean;
discordStatus: string | null;
slackForm: SlackForm;
slackTokenLocked: boolean;
slackAppTokenLocked: boolean;
slackSaving: boolean;
slackStatus: string | null;
signalForm: SignalForm;
signalSaving: boolean;
signalStatus: string | null;
imessageForm: IMessageForm;
imessageSaving: boolean;
imessageStatus: string | null;
onRefresh: (probe: boolean) => void;
onWhatsAppStart: (force: boolean) => void;
onWhatsAppWait: () => void;
onWhatsAppLogout: () => void;
onTelegramChange: (patch: Partial<TelegramForm>) => void;
onTelegramSave: () => void;
onDiscordChange: (patch: Partial<DiscordForm>) => void;
onDiscordSave: () => void;
onSlackChange: (patch: Partial<SlackForm>) => void;
onSlackSave: () => void;
onSignalChange: (patch: Partial<SignalForm>) => void;
onSignalSave: () => void;
onIMessageChange: (patch: Partial<IMessageForm>) => void;
onIMessageSave: () => void;
};
export type ConnectionsChannelData = {
whatsapp?: WhatsAppStatus;
telegram?: TelegramStatus;
discord?: DiscordStatus | null;
slack?: SlackStatus | null;
signal?: SignalStatus | null;
imessage?: IMessageStatus | null;
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null;
};

View File

@ -12,7 +12,7 @@ export function renderNodes(props: NodesProps) {
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<div> <div>
<div class="card-title">Nodes</div> <div class="card-title">Nodes</div>
<div class="card-sub">Paired devices and live connections.</div> <div class="card-sub">Paired devices and live links.</div>
</div> </div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}> <button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"} ${props.loading ? "Loading…" : "Refresh"}

View File

@ -169,7 +169,7 @@ export function renderOverview(props: OverviewProps) {
${authHint ?? ""} ${authHint ?? ""}
</div>` </div>`
: html`<div class="callout" style="margin-top: 14px;"> : html`<div class="callout" style="margin-top: 14px;">
Use Connections to link WhatsApp, Telegram, Discord, Signal, or iMessage. Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
</div>`} </div>`}
</div> </div>
</section> </section>