Config: schema-driven channels and settings

This commit is contained in:
Shadow
2026-01-16 14:13:30 -06:00
committed by Peter Steinberger
parent bcfc9bead5
commit 1ad26d6fea
79 changed files with 2290 additions and 6326 deletions

View File

@@ -0,0 +1,311 @@
import SwiftUI
struct ConfigSchemaForm: View {
@Bindable var store: ChannelsStore
let schema: ConfigSchemaNode
let path: ConfigPath
var body: some View {
self.renderNode(schema, path: path)
}
@ViewBuilder
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> some View {
let value = store.configValue(at: path)
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
switch schema.schemaType {
case "object":
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":
self.renderArray(schema, path: path, value: value, label: label, help: help)
case "boolean":
Toggle(isOn: self.boolBinding(path)) {
if let label { Text(label) } else { Text("Enabled") }
}
.help(help ?? "")
case "number", "integer":
self.renderNumberField(schema, path: path, label: label, help: help)
case "string":
self.renderStringField(schema, path: path, label: label, help: help)
default:
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 ?? false
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)) {
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))
.textFieldStyle(.roundedBorder)
} else {
TextField(placeholder, text: self.stringBinding(path))
.textFieldStyle(.roundedBorder)
}
}
}
@ViewBuilder
private func renderNumberField(
_ schema: ConfigSchemaNode,
path: ConfigPath,
label: String?,
help: String?) -> some View
{
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"))
.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
{
guard let additionalSchema = schema.additionalProperties else { return }
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) -> Binding<String> {
Binding(
get: {
store.configValue(at: path) as? String ?? ""
},
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
}
)
}
private func boolBinding(_ path: ConfigPath) -> Binding<Bool> {
Binding(
get: {
store.configValue(at: path) as? Bool ?? false
},
set: { newValue in
store.updateConfigValue(path: path, value: newValue)
}
)
}
private func numberBinding(_ path: ConfigPath, isInteger: Bool) -> Binding<String> {
Binding(
get: {
guard let value = store.configValue(at: path) else { return "" }
return String(describing: value)
},
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]) -> Binding<Int> {
Binding(
get: {
guard let value = store.configValue(at: path) 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
extension ConnectionsSettings {
extension ChannelsSettings {
private func channelStatus<T: Decodable>(
_ id: String,
as type: T.Type) -> T?
@@ -242,16 +243,18 @@ extension ConnectionsSettings {
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
var isTelegramTokenLocked: Bool {
self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
}
var isDiscordTokenLocked: Bool {
self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
}
var orderedChannels: [ConnectionChannel] {
ConnectionChannel.allCases.sorted { lhs, rhs in
var orderedChannels: [ChannelItem] {
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
let order = self.store.snapshot?.channelOrder ?? fallback
let channels = order.enumerated().map { index, id in
ChannelItem(
id: id,
title: self.resolveChannelTitle(id),
detailTitle: self.resolveChannelDetailTitle(id),
systemImage: self.resolveChannelSystemImage(id),
sortOrder: index)
}
return channels.sorted { lhs, rhs in
let lhsEnabled = self.channelEnabled(lhs)
let rhsEnabled = self.channelEnabled(rhs)
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
@@ -259,11 +262,11 @@ extension ConnectionsSettings {
}
}
var enabledChannels: [ConnectionChannel] {
var enabledChannels: [ChannelItem] {
self.orderedChannels.filter { self.channelEnabled($0) }
}
var availableChannels: [ConnectionChannel] {
var availableChannels: [ChannelItem] {
self.orderedChannels.filter { !self.channelEnabled($0) }
}
@@ -277,143 +280,183 @@ extension ConnectionsSettings {
}
}
func channelEnabled(_ channel: ConnectionChannel) -> Bool {
switch channel {
case .whatsapp:
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return false }
return status.configured || status.linked || status.running
case .telegram:
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
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
}
func channelEnabled(_ channel: ChannelItem) -> Bool {
let status = self.channelStatusDictionary(channel.id)
let configured = status?["configured"]?.boolValue ?? false
let running = status?["running"]?.boolValue ?? false
let connected = status?["connected"]?.boolValue ?? false
let accountActive = self.store.snapshot?.channelAccounts[channel.id]?.contains(
where: { $0.configured == true || $0.running == true || $0.connected == true }) ?? false
return configured || running || connected || accountActive
}
@ViewBuilder
func channelSection(_ channel: ConnectionChannel) -> some View {
switch channel {
case .whatsapp:
func channelSection(_ channel: ChannelItem) -> some View {
if channel.id == "whatsapp" {
self.whatsAppSection
case .telegram:
self.telegramSection
case .discord:
self.discordSection
case .signal:
self.signalSection
case .imessage:
self.imessageSection
} else {
self.genericChannelSection(channel)
}
}
func channelTint(_ channel: ConnectionChannel) -> Color {
switch channel {
case .whatsapp:
self.whatsAppTint
case .telegram:
self.telegramTint
case .discord:
self.discordTint
case .signal:
self.signalTint
case .imessage:
self.imessageTint
func channelTint(_ channel: ChannelItem) -> Color {
switch channel.id {
case "whatsapp":
return self.whatsAppTint
case "telegram":
return self.telegramTint
case "discord":
return self.discordTint
case "signal":
return self.signalTint
case "imessage":
return self.imessageTint
default:
if self.channelHasError(channel) { return .orange }
if self.channelEnabled(channel) { return .green }
return .secondary
}
}
func channelSummary(_ channel: ConnectionChannel) -> String {
switch channel {
case .whatsapp:
self.whatsAppSummary
case .telegram:
self.telegramSummary
case .discord:
self.discordSummary
case .signal:
self.signalSummary
case .imessage:
self.imessageSummary
func channelSummary(_ channel: ChannelItem) -> String {
switch channel.id {
case "whatsapp":
return self.whatsAppSummary
case "telegram":
return self.telegramSummary
case "discord":
return self.discordSummary
case "signal":
return self.signalSummary
case "imessage":
return self.imessageSummary
default:
if self.channelHasError(channel) { return "Error" }
if self.channelEnabled(channel) { return "Active" }
return "Not configured"
}
}
func channelDetails(_ channel: ConnectionChannel) -> String? {
switch channel {
case .whatsapp:
self.whatsAppDetails
case .telegram:
self.telegramDetails
case .discord:
self.discordDetails
case .signal:
self.signalDetails
case .imessage:
self.imessageDetails
func channelDetails(_ channel: ChannelItem) -> String? {
switch channel.id {
case "whatsapp":
return self.whatsAppDetails
case "telegram":
return self.telegramDetails
case "discord":
return self.discordDetails
case "signal":
return self.signalDetails
case "imessage":
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" }
return relativeAge(from: date)
}
func channelLastCheck(_ channel: ConnectionChannel) -> Date? {
switch channel {
case .whatsapp:
func channelLastCheck(_ channel: ChannelItem) -> Date? {
switch channel.id {
case "whatsapp":
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return nil }
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
case .telegram:
case "telegram":
return self
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.lastProbeAt)
case .discord:
case "discord":
return self
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.lastProbeAt)
case .signal:
case "signal":
return self
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
case .imessage:
case "imessage":
return self
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
.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 {
switch channel {
case .whatsapp:
func channelHasError(_ channel: ChannelItem) -> Bool {
switch channel.id {
case "whatsapp":
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
case .telegram:
case "telegram":
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case .discord:
case "discord":
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case .signal:
case "signal":
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case .imessage:
case "imessage":
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return 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
extension ConnectionsSettings {
extension ChannelsSettings {
func date(fromMs ms: Double?) -> Date? {
guard let ms else { return nil }
return Date(timeIntervalSince1970: ms / 1000)

View File

@@ -1,6 +1,6 @@
import SwiftUI
extension ConnectionsSettings {
extension ChannelsSettings {
var body: some View {
HStack(spacing: 0) {
self.sidebar
@@ -57,7 +57,7 @@ extension ConnectionsSettings {
private var emptyDetail: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Connections")
Text("Channels")
.font(.title3.weight(.semibold))
Text("Select a channel to view status and settings.")
.font(.callout)
@@ -67,7 +67,7 @@ extension ConnectionsSettings {
.padding(.vertical, 18)
}
private func channelDetail(_ channel: ConnectionChannel) -> some View {
private func channelDetail(_ channel: ChannelItem) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
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
return Button {
self.selectedChannel = channel
@@ -119,7 +119,7 @@ extension ConnectionsSettings {
.padding(.top, 2)
}
private func detailHeader(for channel: ConnectionChannel) -> some View {
private func detailHeader(for channel: ChannelItem) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
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 = self.configDraft
setValue(&root, path: path, value: value)
self.configDraft = root
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(), 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 Foundation
extension ConnectionsStore {
extension ChannelsStore {
func start() {
guard !self.isPreview else { return }
guard self.pollTask == nil else { return }
self.pollTask = Task.detached { [weak self] in
guard let self else { return }
await self.refresh(probe: true)
await self.loadConfigSchema()
await self.loadConfig()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))

View File

@@ -187,49 +187,10 @@ struct ConfigSnapshot: Codable {
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
@Observable
final class ConnectionsStore {
static let shared = ConnectionsStore()
final class ChannelsStore {
static let shared = ChannelsStore()
var snapshot: ChannelsStatusSnapshot?
var lastError: String?
@@ -240,75 +201,21 @@ final class ConnectionsStore {
var whatsappLoginQrDataUrl: String?
var whatsappLoginConnected: Bool?
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 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 isSavingConfig = false
var configSchemaLoading = false
var configSchema: ConfigSchemaNode?
var configUiHints: [String: ConfigUiHint] = [:]
var configDraft: [String: Any] = [:]
var configDirty = false
let interval: TimeInterval = 45
let isPreview: Bool
var pollTask: Task<Void, Never>?
var configRoot: [String: Any] = [:]
var configLoaded = false
var configHash: String?
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview

View File

@@ -0,0 +1,172 @@
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 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 properties: [String: ConfigSchemaNode] {
guard let props = self.raw["properties"] as? [String: Any] else { return [:] }
return props.compactMapValues { ConfigSchemaNode(raw: $0) }
}
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 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 {
private let isPreview = ProcessInfo.processInfo.isPreview
private let isNixMode = ProcessInfo.processInfo.isNixMode
private let state = AppStateStore.shared
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
@Bindable var store: ChannelsStore
@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")
@State private var browserEnabled: Bool = true
@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
init(store: ChannelsStore = .shared) {
self.store = store
}
var body: some View {
ScrollView { self.content }
.onChange(of: self.modelCatalogPath) { _, _ in
Task { await self.loadModels() }
}
.onChange(of: self.modelCatalogReloadBump) { _, _ in
Task { await self.loadModels() }
}
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
self.hasLoaded = true
await self.loadConfig()
await self.loadModels()
await self.refreshGatewayTalkApiKey()
self.allowAutosave = true
}
ScrollView {
self.content
}
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
self.hasLoaded = true
await self.store.loadConfigSchema()
await self.store.loadConfig()
}
}
}
extension ConfigSettings {
private var content: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 16) {
self.header
self.agentSection
.disabled(self.isNixMode)
self.heartbeatSection
.disabled(self.isNixMode)
self.talkSection
.disabled(self.isNixMode)
self.browserSection
.disabled(self.isNixMode)
if let status = self.store.configStatus {
Text(status)
.font(.callout)
.foregroundStyle(.secondary)
}
self.actionRow
Group {
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)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -94,843 +62,33 @@ extension ConfigSettings {
@ViewBuilder
private var header: some View {
Text("Clawdbot CLI config")
Text("Config")
.font(.title3.weight(.semibold))
Text(self.isNixMode
? "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)
.foregroundStyle(.secondary)
}
private var agentSection: some View {
GroupBox("Agent") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Model")
VStack(alignment: .leading, spacing: 6) {
self.modelPickerField
self.modelMetaLabels
}
}
private var actionRow: some View {
HStack(spacing: 10) {
Button("Reload") {
Task { await self.store.reloadConfigDraft() }
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.disabled(!self.store.configLoaded)
private var modelPickerField: some View {
Button {
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)
Button(self.store.isSavingConfig ? "Saving…" : "Save") {
Task { await self.store.saveConfigDraft() }
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configDirty)
}
.buttonStyle(.plain)
.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)
.buttonStyle(.bordered)
}
}
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 {
static var previews: some View {
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

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

View File

@@ -694,10 +694,10 @@ extension OnboardingView {
systemImage: "bubble.left.and.bubble.right")
self.featureActionRow(
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")
{
self.openSettings(tab: .connections)
self.openSettings(tab: .channels)
}
self.featureRow(
title: "Try Voice Wake",

View File

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