From 1ad26d6fea066d3ac081c4878af9552904ebc59f Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 16 Jan 2026 14:13:30 -0600 Subject: [PATCH] Config: schema-driven channels and settings --- .../Sources/Clawdbot/ChannelConfigForm.swift | 311 ++++++ .../ChannelsSettings+ChannelSections.swift | 139 +++ ...ft => ChannelsSettings+ChannelState.swift} | 239 +++-- ...s.swift => ChannelsSettings+Helpers.swift} | 2 +- ...View.swift => ChannelsSettings+View.swift} | 10 +- .../Sources/Clawdbot/ChannelsSettings.swift | 19 + .../Clawdbot/ChannelsStore+Config.swift | 154 +++ ...le.swift => ChannelsStore+Lifecycle.swift} | 3 +- ...ectionsStore.swift => ChannelsStore.swift} | 109 +- .../Clawdbot/ConfigSchemaSupport.swift | 172 ++++ .../Sources/Clawdbot/ConfigSettings.swift | 938 +----------------- .../ConnectionsSettings+ChannelSections.swift | 707 ------------- .../Clawdbot/ConnectionsSettings.swift | 63 -- .../Clawdbot/ConnectionsStore+Config.swift | 594 ----------- .../Sources/Clawdbot/GatewayConnection.swift | 1 + .../Clawdbot/OnboardingView+Pages.swift | 4 +- .../Sources/Clawdbot/SettingsRootView.swift | 12 +- extensions/msteams/src/channel.ts | 5 +- src/channels/plugins/config-schema.ts | 12 + src/channels/plugins/discord.ts | 3 + src/channels/plugins/imessage.ts | 3 + src/channels/plugins/signal.ts | 3 + src/channels/plugins/slack.ts | 3 + src/channels/plugins/telegram.ts | 3 + src/channels/plugins/types.plugin.ts | 15 + src/channels/plugins/whatsapp.ts | 3 + src/config/schema.test.ts | 48 + src/config/schema.ts | 139 ++- src/gateway/server-bridge-methods-config.ts | 12 +- src/gateway/server-methods/config.ts | 12 +- src/plugins/loader.ts | 12 + src/plugins/registry.ts | 1 + src/plugins/types.ts | 1 + ui/src/ui/app-channels.ts | 34 + ui/src/ui/app-connections.ts | 58 -- ui/src/ui/app-render.ts | 63 +- ui/src/ui/app-settings.ts | 7 +- ui/src/ui/app-view-state.ts | 42 +- ui/src/ui/app.ts | 126 +-- ui/src/ui/controllers/channels.ts | 76 ++ ui/src/ui/controllers/channels.types.ts | 15 + ui/src/ui/controllers/config.test.ts | 128 +-- ui/src/ui/controllers/config.ts | 302 ------ .../controllers/connections.save-discord.ts | 173 ---- .../controllers/connections.save-imessage.ts | 63 -- .../ui/controllers/connections.save-signal.ts | 81 -- .../ui/controllers/connections.save-slack.ts | 138 --- ui/src/ui/controllers/connections.ts | 221 ----- ui/src/ui/controllers/connections.types.ts | 43 - ui/src/ui/focus-mode.browser.test.ts | 4 +- ui/src/ui/navigation.browser.test.ts | 6 +- ui/src/ui/navigation.test.ts | 2 +- ui/src/ui/navigation.ts | 16 +- ui/src/ui/ui-types.ts | 150 --- ui/src/ui/views/channels.config.ts | 134 +++ ui/src/ui/views/channels.discord.ts | 62 ++ ui/src/ui/views/channels.imessage.ts | 62 ++ ui/src/ui/views/channels.shared.ts | 46 + ui/src/ui/views/channels.signal.ts | 66 ++ ui/src/ui/views/channels.slack.ts | 62 ++ ui/src/ui/views/channels.telegram.ts | 113 +++ ui/src/ui/views/channels.ts | 234 +++++ ui/src/ui/views/channels.types.ts | 48 + ...tions.whatsapp.ts => channels.whatsapp.ts} | 9 +- ui/src/ui/views/config-form.ts | 3 +- ui/src/ui/views/config.ts | 338 ------- ui/src/ui/views/connections.action-options.ts | 28 - .../ui/views/connections.discord.actions.ts | 31 - ui/src/ui/views/connections.discord.guilds.ts | 262 ----- ui/src/ui/views/connections.discord.ts | 261 ----- ui/src/ui/views/connections.imessage.ts | 184 ---- ui/src/ui/views/connections.shared.ts | 71 -- ui/src/ui/views/connections.signal.ts | 237 ----- ui/src/ui/views/connections.slack.ts | 391 -------- ui/src/ui/views/connections.telegram.ts | 248 ----- ui/src/ui/views/connections.ts | 141 --- ui/src/ui/views/connections.types.ts | 81 -- ui/src/ui/views/nodes.ts | 2 +- ui/src/ui/views/overview.ts | 2 +- 79 files changed, 2290 insertions(+), 6326 deletions(-) create mode 100644 apps/macos/Sources/Clawdbot/ChannelConfigForm.swift create mode 100644 apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelSections.swift rename apps/macos/Sources/Clawdbot/{ConnectionsSettings+ChannelState.swift => ChannelsSettings+ChannelState.swift} (69%) rename apps/macos/Sources/Clawdbot/{ConnectionsSettings+Helpers.swift => ChannelsSettings+Helpers.swift} (94%) rename apps/macos/Sources/Clawdbot/{ConnectionsSettings+View.swift => ChannelsSettings+View.swift} (95%) create mode 100644 apps/macos/Sources/Clawdbot/ChannelsSettings.swift create mode 100644 apps/macos/Sources/Clawdbot/ChannelsStore+Config.swift rename apps/macos/Sources/Clawdbot/{ConnectionsStore+Lifecycle.swift => ChannelsStore+Lifecycle.swift} (98%) rename apps/macos/Sources/Clawdbot/{ConnectionsStore.swift => ChannelsStore.swift} (62%) create mode 100644 apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift delete mode 100644 apps/macos/Sources/Clawdbot/ConnectionsSettings+ChannelSections.swift delete mode 100644 apps/macos/Sources/Clawdbot/ConnectionsSettings.swift delete mode 100644 apps/macos/Sources/Clawdbot/ConnectionsStore+Config.swift create mode 100644 src/channels/plugins/config-schema.ts create mode 100644 ui/src/ui/app-channels.ts delete mode 100644 ui/src/ui/app-connections.ts create mode 100644 ui/src/ui/controllers/channels.ts create mode 100644 ui/src/ui/controllers/channels.types.ts delete mode 100644 ui/src/ui/controllers/connections.save-discord.ts delete mode 100644 ui/src/ui/controllers/connections.save-imessage.ts delete mode 100644 ui/src/ui/controllers/connections.save-signal.ts delete mode 100644 ui/src/ui/controllers/connections.save-slack.ts delete mode 100644 ui/src/ui/controllers/connections.ts delete mode 100644 ui/src/ui/controllers/connections.types.ts create mode 100644 ui/src/ui/views/channels.config.ts create mode 100644 ui/src/ui/views/channels.discord.ts create mode 100644 ui/src/ui/views/channels.imessage.ts create mode 100644 ui/src/ui/views/channels.shared.ts create mode 100644 ui/src/ui/views/channels.signal.ts create mode 100644 ui/src/ui/views/channels.slack.ts create mode 100644 ui/src/ui/views/channels.telegram.ts create mode 100644 ui/src/ui/views/channels.ts create mode 100644 ui/src/ui/views/channels.types.ts rename ui/src/ui/views/{connections.whatsapp.ts => channels.whatsapp.ts} (92%) delete mode 100644 ui/src/ui/views/connections.action-options.ts delete mode 100644 ui/src/ui/views/connections.discord.actions.ts delete mode 100644 ui/src/ui/views/connections.discord.guilds.ts delete mode 100644 ui/src/ui/views/connections.discord.ts delete mode 100644 ui/src/ui/views/connections.imessage.ts delete mode 100644 ui/src/ui/views/connections.shared.ts delete mode 100644 ui/src/ui/views/connections.signal.ts delete mode 100644 ui/src/ui/views/connections.slack.ts delete mode 100644 ui/src/ui/views/connections.telegram.ts delete mode 100644 ui/src/ui/views/connections.ts delete mode 100644 ui/src/ui/views/connections.types.ts diff --git a/apps/macos/Sources/Clawdbot/ChannelConfigForm.swift b/apps/macos/Sources/Clawdbot/ChannelConfigForm.swift new file mode 100644 index 000000000..86ac815ac --- /dev/null +++ b/apps/macos/Sources/Clawdbot/ChannelConfigForm.swift @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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) + } + } +} diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelSections.swift b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelSections.swift new file mode 100644 index 000000000..ea82aac01 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelSections.swift @@ -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) + } + } +} diff --git a/apps/macos/Sources/Clawdbot/ConnectionsSettings+ChannelState.swift b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift similarity index 69% rename from apps/macos/Sources/Clawdbot/ConnectionsSettings+ChannelState.swift rename to apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift index 58d602223..2b1976a19 100644 --- a/apps/macos/Sources/Clawdbot/ConnectionsSettings+ChannelState.swift +++ b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift @@ -1,6 +1,7 @@ +import ClawdbotProtocol import SwiftUI -extension ConnectionsSettings { +extension ChannelsSettings { private func channelStatus( _ 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 + } } diff --git a/apps/macos/Sources/Clawdbot/ConnectionsSettings+Helpers.swift b/apps/macos/Sources/Clawdbot/ChannelsSettings+Helpers.swift similarity index 94% rename from apps/macos/Sources/Clawdbot/ConnectionsSettings+Helpers.swift rename to apps/macos/Sources/Clawdbot/ChannelsSettings+Helpers.swift index abfdb61de..05b79ca04 100644 --- a/apps/macos/Sources/Clawdbot/ConnectionsSettings+Helpers.swift +++ b/apps/macos/Sources/Clawdbot/ChannelsSettings+Helpers.swift @@ -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) diff --git a/apps/macos/Sources/Clawdbot/ConnectionsSettings+View.swift b/apps/macos/Sources/Clawdbot/ChannelsSettings+View.swift similarity index 95% rename from apps/macos/Sources/Clawdbot/ConnectionsSettings+View.swift rename to apps/macos/Sources/Clawdbot/ChannelsSettings+View.swift index 817c719eb..d1ed16bf6 100644 --- a/apps/macos/Sources/Clawdbot/ConnectionsSettings+View.swift +++ b/apps/macos/Sources/Clawdbot/ChannelsSettings+View.swift @@ -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) diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings.swift b/apps/macos/Sources/Clawdbot/ChannelsSettings.swift new file mode 100644 index 000000000..b1177f003 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/ChannelsSettings.swift @@ -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 + } +} diff --git a/apps/macos/Sources/Clawdbot/ChannelsStore+Config.swift b/apps/macos/Sources/Clawdbot/ChannelsStore+Config.swift new file mode 100644 index 000000000..a1b40c232 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/ChannelsStore+Config.swift @@ -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 + } +} diff --git a/apps/macos/Sources/Clawdbot/ConnectionsStore+Lifecycle.swift b/apps/macos/Sources/Clawdbot/ChannelsStore+Lifecycle.swift similarity index 98% rename from apps/macos/Sources/Clawdbot/ConnectionsStore+Lifecycle.swift rename to apps/macos/Sources/Clawdbot/ChannelsStore+Lifecycle.swift index f24cc3dfb..9ebed11a9 100644 --- a/apps/macos/Sources/Clawdbot/ConnectionsStore+Lifecycle.swift +++ b/apps/macos/Sources/Clawdbot/ChannelsStore+Lifecycle.swift @@ -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)) diff --git a/apps/macos/Sources/Clawdbot/ConnectionsStore.swift b/apps/macos/Sources/Clawdbot/ChannelsStore.swift similarity index 62% rename from apps/macos/Sources/Clawdbot/ConnectionsStore.swift rename to apps/macos/Sources/Clawdbot/ChannelsStore.swift index fe0aa337b..13a68ab2f 100644 --- a/apps/macos/Sources/Clawdbot/ConnectionsStore.swift +++ b/apps/macos/Sources/Clawdbot/ChannelsStore.swift @@ -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? var configRoot: [String: Any] = [:] var configLoaded = false - var configHash: String? init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { self.isPreview = isPreview diff --git a/apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift b/apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift new file mode 100644 index 000000000..0b2c8105d --- /dev/null +++ b/apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift @@ -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 { + 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: ".") +} diff --git a/apps/macos/Sources/Clawdbot/ConfigSettings.swift b/apps/macos/Sources/Clawdbot/ConfigSettings.swift index dac397822..288032e5e 100644 --- a/apps/macos/Sources/Clawdbot/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdbot/ConfigSettings.swift @@ -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 won’t 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() - 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[..= 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 diff --git a/apps/macos/Sources/Clawdbot/ConnectionsSettings+ChannelSections.swift b/apps/macos/Sources/Clawdbot/ConnectionsSettings+ChannelSections.swift deleted file mode 100644 index 8ad413b0a..000000000 --- a/apps/macos/Sources/Clawdbot/ConnectionsSettings+ChannelSections.swift +++ /dev/null @@ -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 won’t 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 won’t 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) - } -} diff --git a/apps/macos/Sources/Clawdbot/ConnectionsSettings.swift b/apps/macos/Sources/Clawdbot/ConnectionsSettings.swift deleted file mode 100644 index 0976f7397..000000000 --- a/apps/macos/Sources/Clawdbot/ConnectionsSettings.swift +++ /dev/null @@ -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 - } -} diff --git a/apps/macos/Sources/Clawdbot/ConnectionsStore+Config.swift b/apps/macos/Sources/Clawdbot/ConnectionsStore+Config.swift deleted file mode 100644 index 076693db4..000000000 --- a/apps/macos/Sources/Clawdbot/ConnectionsStore+Config.swift +++ /dev/null @@ -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() - 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() - 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 - } - } -} diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index 2c61d1cd0..bf569213e 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -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" diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift index 4ad376db7..03ca409c2 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift @@ -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", diff --git a/apps/macos/Sources/Clawdbot/SettingsRootView.swift b/apps/macos/Sources/Clawdbot/SettingsRootView.swift index 87ecd3800..a261d53b4 100644 --- a/apps/macos/Sources/Clawdbot/SettingsRootView.swift +++ b/apps/macos/Sources/Clawdbot/SettingsRootView.swift @@ -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" diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 46e294d5d..bf315cdc8 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,7 +1,9 @@ import type { ClawdbotConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { MSTeamsConfigSchema } from "../../../src/config/zod-schema.providers-core.js"; +import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js"; import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js"; import type { ChannelMessageActionName, ChannelPlugin } from "../../../src/channels/plugins/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { msteamsOnboardingAdapter } from "./onboarding.js"; import { msteamsOutbound } from "./outbound.js"; @@ -64,6 +66,7 @@ export const msteamsPlugin: ChannelPlugin = { media: true, }, reload: { configPrefixes: ["channels.msteams"] }, + configSchema: buildChannelConfigSchema(MSTeamsConfigSchema), config: { listAccountIds: () => [DEFAULT_ACCOUNT_ID], resolveAccount: (cfg) => ({ diff --git a/src/channels/plugins/config-schema.ts b/src/channels/plugins/config-schema.ts new file mode 100644 index 000000000..ff299995a --- /dev/null +++ b/src/channels/plugins/config-schema.ts @@ -0,0 +1,12 @@ +import type { ZodTypeAny } from "zod"; + +import type { ChannelConfigSchema } from "./types.js"; + +export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema { + return { + schema: schema.toJSONSchema({ + target: "draft-07", + unrepresentable: "any", + }) as Record, + }; +} diff --git a/src/channels/plugins/discord.ts b/src/channels/plugins/discord.ts index 59c79948e..1f2ec5508 100644 --- a/src/channels/plugins/discord.ts +++ b/src/channels/plugins/discord.ts @@ -13,7 +13,9 @@ import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js"; import { shouldLogVerbose } from "../../globals.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { getChatChannelMeta } from "../registry.js"; +import { DiscordConfigSchema } from "../../config/zod-schema.providers-core.js"; import { discordMessageActions } from "./actions/discord.js"; +import { buildChannelConfigSchema } from "./config-schema.js"; import { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, @@ -57,6 +59,7 @@ export const discordPlugin: ChannelPlugin = { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, reload: { configPrefixes: ["channels.discord"] }, + configSchema: buildChannelConfigSchema(DiscordConfigSchema), config: { listAccountIds: (cfg) => listDiscordAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), diff --git a/src/channels/plugins/imessage.ts b/src/channels/plugins/imessage.ts index 13f4b7dda..6436d1782 100644 --- a/src/channels/plugins/imessage.ts +++ b/src/channels/plugins/imessage.ts @@ -9,6 +9,8 @@ import { probeIMessage } from "../../imessage/probe.js"; import { sendMessageIMessage } from "../../imessage/send.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { getChatChannelMeta } from "../registry.js"; +import { IMessageConfigSchema } from "../../config/zod-schema.providers-core.js"; +import { buildChannelConfigSchema } from "./config-schema.js"; import { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, @@ -44,6 +46,7 @@ export const imessagePlugin: ChannelPlugin = { media: true, }, reload: { configPrefixes: ["channels.imessage"] }, + configSchema: buildChannelConfigSchema(IMessageConfigSchema), config: { listAccountIds: (cfg) => listIMessageAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), diff --git a/src/channels/plugins/signal.ts b/src/channels/plugins/signal.ts index 48445f666..823fd1db2 100644 --- a/src/channels/plugins/signal.ts +++ b/src/channels/plugins/signal.ts @@ -10,6 +10,8 @@ import { probeSignal } from "../../signal/probe.js"; import { sendMessageSignal } from "../../signal/send.js"; import { normalizeE164 } from "../../utils.js"; import { getChatChannelMeta } from "../registry.js"; +import { SignalConfigSchema } from "../../config/zod-schema.providers-core.js"; +import { buildChannelConfigSchema } from "./config-schema.js"; import { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, @@ -48,6 +50,7 @@ export const signalPlugin: ChannelPlugin = { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, reload: { configPrefixes: ["channels.signal"] }, + configSchema: buildChannelConfigSchema(SignalConfigSchema), config: { listAccountIds: (cfg) => listSignalAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), diff --git a/src/channels/plugins/slack.ts b/src/channels/plugins/slack.ts index 149205767..cade740d8 100644 --- a/src/channels/plugins/slack.ts +++ b/src/channels/plugins/slack.ts @@ -12,6 +12,8 @@ import { import { probeSlack } from "../../slack/probe.js"; import { sendMessageSlack } from "../../slack/send.js"; import { getChatChannelMeta } from "../registry.js"; +import { SlackConfigSchema } from "../../config/zod-schema.providers-core.js"; +import { buildChannelConfigSchema } from "./config-schema.js"; import { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, @@ -80,6 +82,7 @@ export const slackPlugin: ChannelPlugin = { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, reload: { configPrefixes: ["channels.slack"] }, + configSchema: buildChannelConfigSchema(SlackConfigSchema), config: { listAccountIds: (cfg) => listSlackAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), diff --git a/src/channels/plugins/telegram.ts b/src/channels/plugins/telegram.ts index a2e2fdc0d..f157bd210 100644 --- a/src/channels/plugins/telegram.ts +++ b/src/channels/plugins/telegram.ts @@ -17,7 +17,9 @@ import { probeTelegram } from "../../telegram/probe.js"; import { sendMessageTelegram } from "../../telegram/send.js"; import { resolveTelegramToken } from "../../telegram/token.js"; import { getChatChannelMeta } from "../registry.js"; +import { TelegramConfigSchema } from "../../config/zod-schema.providers-core.js"; import { telegramMessageActions } from "./actions/telegram.js"; +import { buildChannelConfigSchema } from "./config-schema.js"; import { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, @@ -77,6 +79,7 @@ export const telegramPlugin: ChannelPlugin = { blockStreaming: true, }, reload: { configPrefixes: ["channels.telegram"] }, + configSchema: buildChannelConfigSchema(TelegramConfigSchema), config: { listAccountIds: (cfg) => listTelegramAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index da95250ab..48c8d46b6 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -29,6 +29,20 @@ import type { // Channel docking: implement this contract in src/channels/plugins/.ts. // biome-ignore lint/suspicious/noExplicitAny: registry aggregates heterogeneous account types. +export type ChannelConfigUiHint = { + label?: string; + help?: string; + advanced?: boolean; + sensitive?: boolean; + placeholder?: string; + itemTemplate?: unknown; +}; + +export type ChannelConfigSchema = { + schema: Record; + uiHints?: Record; +}; + export type ChannelPlugin = { id: ChannelId; meta: ChannelMeta; @@ -37,6 +51,7 @@ export type ChannelPlugin = { // CLI onboarding wizard hooks for this channel. onboarding?: ChannelOnboardingAdapter; config: ChannelConfigAdapter; + configSchema?: ChannelConfigSchema; setup?: ChannelSetupAdapter; pairing?: ChannelPairingAdapter; security?: ChannelSecurityAdapter; diff --git a/src/channels/plugins/whatsapp.ts b/src/channels/plugins/whatsapp.ts index 0c1660f8e..6699f5ab6 100644 --- a/src/channels/plugins/whatsapp.ts +++ b/src/channels/plugins/whatsapp.ts @@ -21,6 +21,8 @@ import { import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; import { getChatChannelMeta } from "../registry.js"; +import { WhatsAppConfigSchema } from "../../config/zod-schema.providers-whatsapp.js"; +import { buildChannelConfigSchema } from "./config-schema.js"; import { createWhatsAppLoginTool } from "./agent-tools/whatsapp-login.js"; import { resolveWhatsAppGroupRequireMention } from "./group-mentions.js"; import { formatPairingApproveHint } from "./helpers.js"; @@ -60,6 +62,7 @@ export const whatsappPlugin: ChannelPlugin = { }, reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, gatewayMethods: ["web.login.start", "web.login.wait"], + configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), config: { listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index ff98c9e82..17b298899 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -36,4 +36,52 @@ describe("config schema", () => { ); expect(res.uiHints["plugins.entries.voice-call.config.twilio.authToken"]?.sensitive).toBe(true); }); + + it("merges plugin + channel schemas", () => { + const res = buildConfigSchema({ + plugins: [ + { + id: "voice-call", + name: "Voice Call", + configSchema: { + type: "object", + properties: { + provider: { type: "string" }, + }, + }, + }, + ], + channels: [ + { + id: "matrix", + label: "Matrix", + configSchema: { + type: "object", + properties: { + accessToken: { type: "string" }, + }, + }, + }, + ], + }); + + const schema = res.schema as { + properties?: Record; + }; + const pluginsNode = schema.properties?.plugins as Record | undefined; + const entriesNode = pluginsNode?.properties as Record | undefined; + const entriesProps = entriesNode?.entries as Record | undefined; + const entryProps = entriesProps?.properties as Record | undefined; + const pluginEntry = entryProps?.["voice-call"] as Record | undefined; + const pluginConfig = pluginEntry?.properties as Record | undefined; + const pluginConfigSchema = pluginConfig?.config as Record | undefined; + const pluginConfigProps = pluginConfigSchema?.properties as Record | undefined; + expect(pluginConfigProps?.provider).toBeTruthy(); + + const channelsNode = schema.properties?.channels as Record | undefined; + const channelsProps = channelsNode?.properties as Record | undefined; + const channelSchema = channelsProps?.matrix as Record | undefined; + const channelProps = channelSchema?.properties as Record | undefined; + expect(channelProps?.accessToken).toBeTruthy(); + }); }); diff --git a/src/config/schema.ts b/src/config/schema.ts index 3c3269019..36de104f7 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -16,6 +16,8 @@ export type ConfigUiHints = Record; export type ConfigSchema = ReturnType; +type JsonSchemaNode = Record; + export type ConfigSchemaResponse = { schema: ConfigSchema; uiHints: ConfigUiHints; @@ -31,12 +33,15 @@ export type PluginUiMetadata = { string, Pick >; + configSchema?: JsonSchemaNode; }; export type ChannelUiMetadata = { id: string; label?: string; description?: string; + configSchema?: JsonSchemaNode; + configUiHints?: Record; }; const GROUP_LABELS: Record = { @@ -433,6 +438,51 @@ function isSensitivePath(path: string): boolean { return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path)); } +type JsonSchemaObject = JsonSchemaNode & { + type?: string | string[]; + properties?: Record; + required?: string[]; + additionalProperties?: JsonSchemaObject | boolean; +}; + +function cloneSchema(value: T): T { + if (typeof structuredClone === "function") return structuredClone(value); + return JSON.parse(JSON.stringify(value)) as T; +} + +function asSchemaObject(value: unknown): JsonSchemaObject | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as JsonSchemaObject; +} + +function isObjectSchema(schema: JsonSchemaObject): boolean { + const type = schema.type; + if (type === "object") return true; + if (Array.isArray(type) && type.includes("object")) return true; + return Boolean(schema.properties || schema.additionalProperties); +} + +function mergeObjectSchema(base: JsonSchemaObject, extension: JsonSchemaObject): JsonSchemaObject { + const mergedRequired = new Set([ + ...(base.required ?? []), + ...(extension.required ?? []), + ]); + const merged: JsonSchemaObject = { + ...base, + ...extension, + properties: { + ...base.properties, + ...extension.properties, + }, + }; + if (mergedRequired.size > 0) { + merged.required = Array.from(mergedRequired); + } + const additional = extension.additionalProperties ?? base.additionalProperties; + if (additional !== undefined) merged.additionalProperties = additional; + return merged; +} + function buildBaseHints(): ConfigUiHints { const hints: ConfigUiHints = {}; for (const [group, label] of Object.entries(GROUP_LABELS)) { @@ -520,12 +570,90 @@ function applyChannelHints(hints: ConfigUiHints, channels: ChannelUiMetadata[]): ...(label ? { label } : {}), ...(help ? { help } : {}), }; + + const uiHints = channel.configUiHints ?? {}; + for (const [relPathRaw, hint] of Object.entries(uiHints)) { + const relPath = relPathRaw.trim().replace(/^\./, ""); + if (!relPath) continue; + const key = `${basePath}.${relPath}`; + next[key] = { + ...next[key], + ...hint, + }; + } } return next; } +function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]): ConfigSchema { + const next = cloneSchema(schema); + const root = asSchemaObject(next); + const pluginsNode = asSchemaObject(root?.properties?.plugins); + const entriesNode = asSchemaObject(pluginsNode?.properties?.entries); + if (!entriesNode) return next; + + const entryBase = asSchemaObject(entriesNode.additionalProperties); + const entryProperties = entriesNode.properties ?? {}; + entriesNode.properties = entryProperties; + + for (const plugin of plugins) { + if (!plugin.configSchema) continue; + const entrySchema = entryBase ? cloneSchema(entryBase) : ({ type: "object" } as JsonSchemaObject); + const entryObject = asSchemaObject(entrySchema) ?? ({ type: "object" } as JsonSchemaObject); + const baseConfigSchema = asSchemaObject(entryObject.properties?.config); + const pluginSchema = asSchemaObject(plugin.configSchema); + const nextConfigSchema = + baseConfigSchema && pluginSchema && isObjectSchema(baseConfigSchema) && isObjectSchema(pluginSchema) + ? mergeObjectSchema(baseConfigSchema, pluginSchema) + : cloneSchema(plugin.configSchema); + + entryObject.properties = { + ...entryObject.properties, + config: nextConfigSchema, + }; + entryProperties[plugin.id] = entryObject; + } + + return next; +} + +function applyChannelSchemas(schema: ConfigSchema, channels: ChannelUiMetadata[]): ConfigSchema { + const next = cloneSchema(schema); + const root = asSchemaObject(next); + const channelsNode = asSchemaObject(root?.properties?.channels); + if (!channelsNode) return next; + const channelProps = channelsNode.properties ?? {}; + channelsNode.properties = channelProps; + + for (const channel of channels) { + if (!channel.configSchema) continue; + const existing = asSchemaObject(channelProps[channel.id]); + const incoming = asSchemaObject(channel.configSchema); + if (existing && incoming && isObjectSchema(existing) && isObjectSchema(incoming)) { + channelProps[channel.id] = mergeObjectSchema(existing, incoming); + } else { + channelProps[channel.id] = cloneSchema(channel.configSchema); + } + } + + return next; +} + let cachedBase: ConfigSchemaResponse | null = null; +function stripChannelSchema(schema: ConfigSchema): ConfigSchema { + const next = cloneSchema(schema); + const root = asSchemaObject(next); + if (!root || !root.properties) return next; + const channelsNode = asSchemaObject(root.properties.channels); + if (channelsNode) { + channelsNode.properties = {}; + channelsNode.required = []; + channelsNode.additionalProperties = true; + } + return next; +} + function buildBaseConfigSchema(): ConfigSchemaResponse { if (cachedBase) return cachedBase; const schema = ClawdbotSchema.toJSONSchema({ @@ -535,7 +663,7 @@ function buildBaseConfigSchema(): ConfigSchemaResponse { schema.title = "ClawdbotConfig"; const hints = applySensitiveHints(buildBaseHints()); const next = { - schema, + schema: stripChannelSchema(schema), uiHints: hints, version: VERSION, generatedAt: new Date().toISOString(), @@ -552,11 +680,16 @@ export function buildConfigSchema(params?: { const plugins = params?.plugins ?? []; const channels = params?.channels ?? []; if (plugins.length === 0 && channels.length === 0) return base; - const merged = applySensitiveHints( + const mergedHints = applySensitiveHints( applyChannelHints(applyPluginHints(base.uiHints, plugins), channels), ); + const mergedSchema = applyChannelSchemas( + applyPluginSchemas(base.schema, plugins), + channels, + ); return { ...base, - uiHints: merged, + schema: mergedSchema, + uiHints: mergedHints, }; } diff --git a/src/gateway/server-bridge-methods-config.ts b/src/gateway/server-bridge-methods-config.ts index 230574f37..42e338724 100644 --- a/src/gateway/server-bridge-methods-config.ts +++ b/src/gateway/server-bridge-methods-config.ts @@ -11,6 +11,7 @@ import { import { applyLegacyMigrations } from "../config/legacy.js"; import { applyMergePatch } from "../config/merge-patch.js"; import { buildConfigSchema } from "../config/schema.js"; +import { listChannelPlugins } from "../channels/plugins/index.js"; import { loadClawdbotPlugins } from "../plugins/loader.js"; import { ErrorCodes, @@ -114,11 +115,14 @@ export const handleConfigBridgeMethods: BridgeMethodHandler = async ( name: plugin.name, description: plugin.description, configUiHints: plugin.configUiHints, + configSchema: plugin.configJsonSchema, })), - channels: pluginRegistry.channels.map((entry) => ({ - id: entry.plugin.id, - label: entry.plugin.meta.label, - description: entry.plugin.meta.blurb, + channels: listChannelPlugins().map((entry) => ({ + id: entry.id, + label: entry.meta.label, + description: entry.meta.blurb, + configSchema: entry.configSchema?.schema, + configUiHints: entry.configSchema?.uiHints, })), }); return { ok: true, payloadJSON: JSON.stringify(schema) }; diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index ba5f8d740..adcac10ff 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -17,6 +17,7 @@ import { type RestartSentinelPayload, writeRestartSentinel, } from "../../infra/restart-sentinel.js"; +import { listChannelPlugins } from "../../channels/plugins/index.js"; import { loadClawdbotPlugins } from "../../plugins/loader.js"; import { ErrorCodes, @@ -127,11 +128,14 @@ export const configHandlers: GatewayRequestHandlers = { name: plugin.name, description: plugin.description, configUiHints: plugin.configUiHints, + configSchema: plugin.configJsonSchema, })), - channels: pluginRegistry.channels.map((entry) => ({ - id: entry.plugin.id, - label: entry.plugin.meta.label, - description: entry.plugin.meta.blurb, + channels: listChannelPlugins().map((entry) => ({ + id: entry.id, + label: entry.meta.label, + description: entry.meta.blurb, + configSchema: entry.configSchema?.schema, + configUiHints: entry.configSchema?.uiHints, })), }); respond(true, schema, undefined); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 942406e86..c8d0842ec 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -197,6 +197,7 @@ function createPluginRecord(params: { httpHandlers: 0, configSchema: params.configSchema, configUiHints: undefined, + configJsonSchema: undefined, }; } @@ -302,6 +303,17 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi PluginConfigUiHint >) : undefined; + record.configJsonSchema = + definition?.configSchema && + typeof definition.configSchema === "object" && + (definition.configSchema as { jsonSchema?: unknown }).jsonSchema && + typeof (definition.configSchema as { jsonSchema?: unknown }).jsonSchema === "object" && + !Array.isArray((definition.configSchema as { jsonSchema?: unknown }).jsonSchema) + ? ((definition.configSchema as { jsonSchema?: unknown }).jsonSchema as Record< + string, + unknown + >) + : undefined; const validatedConfig = validatePluginConfig({ schema: definition?.configSchema, diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 897376d41..fa7a4f446 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -80,6 +80,7 @@ export type PluginRecord = { httpHandlers: number; configSchema: boolean; configUiHints?: Record; + configJsonSchema?: Record; }; export type PluginRegistry = { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 69ccfe501..fc4cc9ec5 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -42,6 +42,7 @@ export type ClawdbotPluginConfigSchema = { parse?: (value: unknown) => unknown; validate?: (value: unknown) => PluginConfigValidation; uiHints?: Record; + jsonSchema?: Record; }; export type ClawdbotPluginToolContext = { diff --git a/ui/src/ui/app-channels.ts b/ui/src/ui/app-channels.ts new file mode 100644 index 000000000..3647f00dd --- /dev/null +++ b/ui/src/ui/app-channels.ts @@ -0,0 +1,34 @@ +import { + loadChannels, + logoutWhatsApp, + startWhatsAppLogin, + waitWhatsAppLogin, +} from "./controllers/channels"; +import { loadConfig, saveConfig } from "./controllers/config"; +import type { ClawdbotApp } from "./app"; + +export async function handleWhatsAppStart(host: ClawdbotApp, force: boolean) { + await startWhatsAppLogin(host, force); + await loadChannels(host, true); +} + +export async function handleWhatsAppWait(host: ClawdbotApp) { + await waitWhatsAppLogin(host); + await loadChannels(host, true); +} + +export async function handleWhatsAppLogout(host: ClawdbotApp) { + await logoutWhatsApp(host); + await loadChannels(host, true); +} + +export async function handleChannelConfigSave(host: ClawdbotApp) { + await saveConfig(host); + await loadConfig(host); + await loadChannels(host, true); +} + +export async function handleChannelConfigReload(host: ClawdbotApp) { + await loadConfig(host); + await loadChannels(host, true); +} diff --git a/ui/src/ui/app-connections.ts b/ui/src/ui/app-connections.ts deleted file mode 100644 index a43c1948c..000000000 --- a/ui/src/ui/app-connections.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - loadChannels, - logoutWhatsApp, - saveDiscordConfig, - saveIMessageConfig, - saveSlackConfig, - saveSignalConfig, - saveTelegramConfig, - startWhatsAppLogin, - waitWhatsAppLogin, -} from "./controllers/connections"; -import { loadConfig } from "./controllers/config"; -import type { ClawdbotApp } from "./app"; - -export async function handleWhatsAppStart(host: ClawdbotApp, force: boolean) { - await startWhatsAppLogin(host, force); - await loadChannels(host, true); -} - -export async function handleWhatsAppWait(host: ClawdbotApp) { - await waitWhatsAppLogin(host); - await loadChannels(host, true); -} - -export async function handleWhatsAppLogout(host: ClawdbotApp) { - await logoutWhatsApp(host); - await loadChannels(host, true); -} - -export async function handleTelegramSave(host: ClawdbotApp) { - await saveTelegramConfig(host); - await loadConfig(host); - await loadChannels(host, true); -} - -export async function handleDiscordSave(host: ClawdbotApp) { - await saveDiscordConfig(host); - await loadConfig(host); - await loadChannels(host, true); -} - -export async function handleSlackSave(host: ClawdbotApp) { - await saveSlackConfig(host); - await loadConfig(host); - await loadChannels(host, true); -} - -export async function handleSignalSave(host: ClawdbotApp) { - await saveSignalConfig(host); - await loadConfig(host); - await loadChannels(host, true); -} - -export async function handleIMessageSave(host: ClawdbotApp) { - await saveIMessageConfig(host); - await loadConfig(host); - await loadChannels(host, true); -} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 08fa583ae..60cadb0f2 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -27,18 +27,10 @@ import type { SkillStatusReport, StatusSummary, } from "./types"; -import type { - ChatQueueItem, - CronFormState, - DiscordForm, - IMessageForm, - SlackForm, - SignalForm, - TelegramForm, -} from "./ui-types"; +import type { ChatQueueItem, CronFormState } from "./ui-types"; import { renderChat } from "./views/chat"; import { renderConfig } from "./views/config"; -import { renderConnections } from "./views/connections"; +import { renderChannels } from "./views/channels"; import { renderCron } from "./views/cron"; import { renderDebug } from "./views/debug"; import { renderInstances } from "./views/instances"; @@ -48,14 +40,7 @@ import { renderOverview } from "./views/overview"; import { renderSessions } from "./views/sessions"; import { renderSkills } from "./views/skills"; import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers"; -import { - loadChannels, - updateDiscordForm, - updateIMessageForm, - updateSlackForm, - updateSignalForm, - updateTelegramForm, -} from "./controllers/connections"; +import { loadChannels } from "./controllers/channels"; import { loadPresence } from "./controllers/presence"; import { deleteSession, loadSessions, patchSession } from "./controllers/sessions"; import { @@ -205,8 +190,8 @@ export function renderApp(state: AppViewState) { }) : nothing} - ${state.tab === "connections" - ? renderConnections({ + ${state.tab === "channels" + ? renderChannels({ connected: state.connected, loading: state.channelsLoading, snapshot: state.channelsSnapshot, @@ -216,39 +201,19 @@ export function renderApp(state: AppViewState) { whatsappQrDataUrl: state.whatsappLoginQrDataUrl, whatsappConnected: state.whatsappLoginConnected, whatsappBusy: state.whatsappBusy, - telegramForm: state.telegramForm, - telegramTokenLocked: state.telegramTokenLocked, - telegramSaving: state.telegramSaving, - telegramStatus: state.telegramConfigStatus, - discordForm: state.discordForm, - discordTokenLocked: state.discordTokenLocked, - discordSaving: state.discordSaving, - discordStatus: state.discordConfigStatus, - slackForm: state.slackForm, - slackTokenLocked: state.slackTokenLocked, - slackAppTokenLocked: state.slackAppTokenLocked, - slackSaving: state.slackSaving, - slackStatus: state.slackConfigStatus, - signalForm: state.signalForm, - signalSaving: state.signalSaving, - signalStatus: state.signalConfigStatus, - imessageForm: state.imessageForm, - imessageSaving: state.imessageSaving, - imessageStatus: state.imessageConfigStatus, + configSchema: state.configSchema, + configSchemaLoading: state.configSchemaLoading, + configForm: state.configForm, + configUiHints: state.configUiHints, + configSaving: state.configSaving, + configFormDirty: state.configFormDirty, onRefresh: (probe) => loadChannels(state, probe), onWhatsAppStart: (force) => state.handleWhatsAppStart(force), onWhatsAppWait: () => state.handleWhatsAppWait(), onWhatsAppLogout: () => state.handleWhatsAppLogout(), - onTelegramChange: (patch) => updateTelegramForm(state, patch), - onTelegramSave: () => state.handleTelegramSave(), - onDiscordChange: (patch) => updateDiscordForm(state, patch), - onDiscordSave: () => state.handleDiscordSave(), - onSlackChange: (patch) => updateSlackForm(state, patch), - onSlackSave: () => state.handleSlackSave(), - onSignalChange: (patch) => updateSignalForm(state, patch), - onSignalSave: () => state.handleSignalSave(), - onIMessageChange: (patch) => updateIMessageForm(state, patch), - onIMessageSave: () => state.handleIMessageSave(), + onConfigPatch: (path, value) => updateConfigFormValue(state, path, value), + onConfigSave: () => state.handleChannelConfigSave(), + onConfigReload: () => state.handleChannelConfigReload(), }) : nothing} diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 2e485307d..5cf41677c 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -1,6 +1,6 @@ import { loadConfig, loadConfigSchema } from "./controllers/config"; import { loadCronJobs, loadCronStatus } from "./controllers/cron"; -import { loadChannels } from "./controllers/connections"; +import { loadChannels } from "./controllers/channels"; import { loadDebug } from "./controllers/debug"; import { loadLogs } from "./controllers/logs"; import { loadNodes } from "./controllers/nodes"; @@ -125,7 +125,7 @@ export function setTheme( export async function refreshActiveTab(host: SettingsHost) { if (host.tab === "overview") await loadOverview(host); - if (host.tab === "connections") await loadConnections(host); + if (host.tab === "channels") await loadChannelsTab(host); if (host.tab === "instances") await loadPresence(host as unknown as ClawdbotApp); if (host.tab === "sessions") await loadSessions(host as unknown as ClawdbotApp); if (host.tab === "cron") await loadCron(host); @@ -256,9 +256,10 @@ export async function loadOverview(host: SettingsHost) { ]); } -export async function loadConnections(host: SettingsHost) { +export async function loadChannelsTab(host: SettingsHost) { await Promise.all([ loadChannels(host as unknown as ClawdbotApp, true), + loadConfigSchema(host as unknown as ClawdbotApp), loadConfig(host as unknown as ClawdbotApp), ]); } diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 4d4750793..ddee09aaf 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -17,15 +17,7 @@ import type { SkillStatusReport, StatusSummary, } from "./types"; -import type { - ChatQueueItem, - CronFormState, - DiscordForm, - IMessageForm, - SlackForm, - SignalForm, - TelegramForm, -} from "./ui-types"; +import type { ChatQueueItem, CronFormState } from "./ui-types"; import type { EventLogEntry } from "./app-events"; import type { SkillMessage } from "./controllers/skills"; @@ -73,25 +65,7 @@ export type AppViewState = { whatsappLoginQrDataUrl: string | null; whatsappLoginConnected: boolean | null; whatsappBusy: boolean; - telegramForm: TelegramForm; - telegramSaving: boolean; - telegramTokenLocked: boolean; - telegramConfigStatus: string | null; - discordForm: DiscordForm; - discordSaving: boolean; - discordTokenLocked: boolean; - discordConfigStatus: string | null; - slackForm: SlackForm; - slackSaving: boolean; - slackTokenLocked: boolean; - slackAppTokenLocked: boolean; - slackConfigStatus: string | null; - signalForm: SignalForm; - signalSaving: boolean; - signalConfigStatus: string | null; - imessageForm: IMessageForm; - imessageSaving: boolean; - imessageConfigStatus: string | null; + configFormDirty: boolean; presenceLoading: boolean; presenceEntries: PresenceEntry[]; presenceError: string | null; @@ -145,11 +119,8 @@ export type AppViewState = { handleWhatsAppStart: (force: boolean) => Promise; handleWhatsAppWait: () => Promise; handleWhatsAppLogout: () => Promise; - handleTelegramSave: () => Promise; - handleDiscordSave: () => Promise; - handleSlackSave: () => Promise; - handleSignalSave: () => Promise; - handleIMessageSave: () => Promise; + handleChannelConfigSave: () => Promise; + handleChannelConfigReload: () => Promise; handleConfigLoad: () => Promise; handleConfigSave: () => Promise; handleConfigApply: () => Promise; @@ -188,10 +159,5 @@ export type AppViewState = { handleLogsLevelFilterToggle: (level: LogLevel) => void; handleLogsAutoFollowToggle: (next: boolean) => void; handleCallDebugMethod: (method: string, params: string) => Promise; - handleUpdateDiscordForm: (path: string, value: unknown) => void; - handleUpdateSlackForm: (path: string, value: unknown) => void; - handleUpdateSignalForm: (path: string, value: unknown) => void; - handleUpdateTelegramForm: (path: string, value: unknown) => void; - handleUpdateIMessageForm: (path: string, value: unknown) => void; }; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 17e39a3a3..b2415722c 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -21,17 +21,7 @@ import type { SkillStatusReport, StatusSummary, } from "./types"; -import { - defaultDiscordActions, - defaultSlackActions, - type ChatQueueItem, - type CronFormState, - type DiscordForm, - type IMessageForm, - type SlackForm, - type SignalForm, - type TelegramForm, -} from "./ui-types"; +import { type ChatQueueItem, type CronFormState } from "./ui-types"; import type { EventLogEntry } from "./app-events"; import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults"; import { @@ -66,15 +56,12 @@ import { removeQueuedMessage as removeQueuedMessageInternal, } from "./app-chat"; import { - handleDiscordSave as handleDiscordSaveInternal, - handleIMessageSave as handleIMessageSaveInternal, - handleSignalSave as handleSignalSaveInternal, - handleSlackSave as handleSlackSaveInternal, - handleTelegramSave as handleTelegramSaveInternal, + handleChannelConfigReload as handleChannelConfigReloadInternal, + handleChannelConfigSave as handleChannelConfigSaveInternal, handleWhatsAppLogout as handleWhatsAppLogoutInternal, handleWhatsAppStart as handleWhatsAppStartInternal, handleWhatsAppWait as handleWhatsAppWaitInternal, -} from "./app-connections"; +} from "./app-channels"; declare global { interface Window { @@ -143,91 +130,6 @@ export class ClawdbotApp extends LitElement { @state() whatsappLoginQrDataUrl: string | null = null; @state() whatsappLoginConnected: boolean | null = null; @state() whatsappBusy = false; - @state() telegramForm: TelegramForm = { - token: "", - requireMention: true, - groupsWildcardEnabled: false, - allowFrom: "", - proxy: "", - webhookUrl: "", - webhookSecret: "", - webhookPath: "", - }; - @state() telegramSaving = false; - @state() telegramTokenLocked = false; - @state() telegramConfigStatus: string | null = null; - @state() discordForm: DiscordForm = { - enabled: true, - token: "", - dmEnabled: true, - allowFrom: "", - groupEnabled: false, - groupChannels: "", - mediaMaxMb: "", - historyLimit: "", - textChunkLimit: "", - guilds: [], - actions: { ...defaultDiscordActions }, - slashEnabled: false, - slashName: "", - slashSessionPrefix: "", - slashEphemeral: true, - }; - @state() discordSaving = false; - @state() discordTokenLocked = false; - @state() discordConfigStatus: string | null = null; - @state() slackForm: SlackForm = { - enabled: true, - botToken: "", - appToken: "", - dmEnabled: true, - allowFrom: "", - groupEnabled: false, - groupChannels: "", - mediaMaxMb: "", - textChunkLimit: "", - reactionNotifications: "own", - reactionAllowlist: "", - slashEnabled: false, - slashName: "", - slashSessionPrefix: "", - slashEphemeral: true, - actions: { ...defaultSlackActions }, - channels: [], - }; - @state() slackSaving = false; - @state() slackTokenLocked = false; - @state() slackAppTokenLocked = false; - @state() slackConfigStatus: string | null = null; - @state() signalForm: SignalForm = { - enabled: true, - account: "", - httpUrl: "", - httpHost: "", - httpPort: "", - cliPath: "", - autoStart: true, - receiveMode: "", - ignoreAttachments: false, - ignoreStories: false, - sendReadReceipts: false, - allowFrom: "", - mediaMaxMb: "", - }; - @state() signalSaving = false; - @state() signalConfigStatus: string | null = null; - @state() imessageForm: IMessageForm = { - enabled: true, - cliPath: "", - dbPath: "", - service: "auto", - region: "", - allowFrom: "", - includeAttachments: false, - mediaMaxMb: "", - }; - @state() imessageSaving = false; - @state() imessageConfigStatus: string | null = null; @state() presenceLoading = false; @state() presenceEntries: PresenceEntry[] = []; @@ -439,24 +341,12 @@ export class ClawdbotApp extends LitElement { await handleWhatsAppLogoutInternal(this); } - async handleTelegramSave() { - await handleTelegramSaveInternal(this); + async handleChannelConfigSave() { + await handleChannelConfigSaveInternal(this); } - async handleDiscordSave() { - await handleDiscordSaveInternal(this); - } - - async handleSlackSave() { - await handleSlackSaveInternal(this); - } - - async handleSignalSave() { - await handleSignalSaveInternal(this); - } - - async handleIMessageSave() { - await handleIMessageSaveInternal(this); + async handleChannelConfigReload() { + await handleChannelConfigReloadInternal(this); } // Sidebar handlers for tool output viewing diff --git a/ui/src/ui/controllers/channels.ts b/ui/src/ui/controllers/channels.ts new file mode 100644 index 000000000..7e9e6ee1d --- /dev/null +++ b/ui/src/ui/controllers/channels.ts @@ -0,0 +1,76 @@ +import type { ChannelsStatusSnapshot } from "../types"; +import type { ChannelsState } from "./channels.types"; + +export type { ChannelsState }; + +export async function loadChannels(state: ChannelsState, probe: boolean) { + if (!state.client || !state.connected) return; + if (state.channelsLoading) return; + state.channelsLoading = true; + state.channelsError = null; + try { + const res = (await state.client.request("channels.status", { + probe, + timeoutMs: 8000, + })) as ChannelsStatusSnapshot; + state.channelsSnapshot = res; + state.channelsLastSuccess = Date.now(); + } catch (err) { + state.channelsError = String(err); + } finally { + state.channelsLoading = false; + } +} + +export async function startWhatsAppLogin(state: ChannelsState, force: boolean) { + if (!state.client || !state.connected || state.whatsappBusy) return; + state.whatsappBusy = true; + try { + const res = (await state.client.request("web.login.start", { + force, + timeoutMs: 30000, + })) as { message?: string; qrDataUrl?: string }; + state.whatsappLoginMessage = res.message ?? null; + state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null; + state.whatsappLoginConnected = null; + } catch (err) { + state.whatsappLoginMessage = String(err); + state.whatsappLoginQrDataUrl = null; + state.whatsappLoginConnected = null; + } finally { + state.whatsappBusy = false; + } +} + +export async function waitWhatsAppLogin(state: ChannelsState) { + if (!state.client || !state.connected || state.whatsappBusy) return; + state.whatsappBusy = true; + try { + const res = (await state.client.request("web.login.wait", { + timeoutMs: 120000, + })) as { connected?: boolean; message?: string }; + state.whatsappLoginMessage = res.message ?? null; + state.whatsappLoginConnected = res.connected ?? null; + if (res.connected) state.whatsappLoginQrDataUrl = null; + } catch (err) { + state.whatsappLoginMessage = String(err); + state.whatsappLoginConnected = null; + } finally { + state.whatsappBusy = false; + } +} + +export async function logoutWhatsApp(state: ChannelsState) { + if (!state.client || !state.connected || state.whatsappBusy) return; + state.whatsappBusy = true; + try { + await state.client.request("channels.logout", { channel: "whatsapp" }); + state.whatsappLoginMessage = "Logged out."; + state.whatsappLoginQrDataUrl = null; + state.whatsappLoginConnected = null; + } catch (err) { + state.whatsappLoginMessage = String(err); + } finally { + state.whatsappBusy = false; + } +} diff --git a/ui/src/ui/controllers/channels.types.ts b/ui/src/ui/controllers/channels.types.ts new file mode 100644 index 000000000..15d6d08c9 --- /dev/null +++ b/ui/src/ui/controllers/channels.types.ts @@ -0,0 +1,15 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { ChannelsStatusSnapshot } from "../types"; + +export type ChannelsState = { + client: GatewayBrowserClient | null; + connected: boolean; + channelsLoading: boolean; + channelsSnapshot: ChannelsStatusSnapshot | null; + channelsError: string | null; + channelsLastSuccess: number | null; + whatsappLoginMessage: string | null; + whatsappLoginQrDataUrl: string | null; + whatsappLoginConnected: boolean | null; + whatsappBusy: boolean; +}; diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index a4b223014..408a7d63b 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -7,92 +7,6 @@ import { updateConfigFormValue, type ConfigState, } from "./config"; -import { - defaultDiscordActions, - defaultSlackActions, - type DiscordForm, - type IMessageForm, - type SignalForm, - type SlackForm, - type TelegramForm, -} from "../ui-types"; - -const baseTelegramForm: TelegramForm = { - token: "", - requireMention: true, - groupsWildcardEnabled: false, - allowFrom: "", - proxy: "", - webhookUrl: "", - webhookSecret: "", - webhookPath: "", -}; - -const baseDiscordForm: DiscordForm = { - enabled: true, - token: "", - dmEnabled: true, - allowFrom: "", - groupEnabled: false, - groupChannels: "", - mediaMaxMb: "", - historyLimit: "", - textChunkLimit: "", - replyToMode: "off", - guilds: [], - actions: { ...defaultDiscordActions }, - slashEnabled: false, - slashName: "", - slashSessionPrefix: "", - slashEphemeral: true, -}; - -const baseSlackForm: SlackForm = { - enabled: true, - botToken: "", - appToken: "", - dmEnabled: true, - allowFrom: "", - groupEnabled: false, - groupChannels: "", - mediaMaxMb: "", - textChunkLimit: "", - reactionNotifications: "own", - reactionAllowlist: "", - slashEnabled: false, - slashName: "", - slashSessionPrefix: "", - slashEphemeral: true, - actions: { ...defaultSlackActions }, - channels: [], -}; - -const baseSignalForm: SignalForm = { - enabled: true, - account: "", - httpUrl: "", - httpHost: "", - httpPort: "", - cliPath: "", - autoStart: true, - receiveMode: "", - ignoreAttachments: false, - ignoreStories: false, - sendReadReceipts: false, - allowFrom: "", - mediaMaxMb: "", -}; - -const baseIMessageForm: IMessageForm = { - enabled: true, - cliPath: "", - dbPath: "", - service: "auto", - region: "", - allowFrom: "", - includeAttachments: false, - mediaMaxMb: "", -}; function createState(): ConfigState { return { @@ -115,40 +29,10 @@ function createState(): ConfigState { configFormDirty: false, configFormMode: "form", lastError: null, - telegramForm: { ...baseTelegramForm }, - discordForm: { ...baseDiscordForm }, - slackForm: { ...baseSlackForm }, - signalForm: { ...baseSignalForm }, - imessageForm: { ...baseIMessageForm }, - telegramConfigStatus: null, - discordConfigStatus: null, - slackConfigStatus: null, - signalConfigStatus: null, - imessageConfigStatus: null, }; } describe("applyConfigSnapshot", () => { - it("handles missing slack config without throwing", () => { - const state = createState(); - applyConfigSnapshot(state, { - config: { - channels: { - telegram: {}, - discord: {}, - signal: {}, - imessage: {}, - }, - }, - valid: true, - issues: [], - raw: "{}", - }); - - expect(state.slackForm.botToken).toBe(""); - expect(state.slackForm.actions).toEqual(defaultSlackActions); - }); - it("does not clobber form edits while dirty", () => { const state = createState(); state.configFormMode = "form"; @@ -167,6 +51,18 @@ describe("applyConfigSnapshot", () => { "{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n", ); }); + + it("updates config form when clean", () => { + const state = createState(); + applyConfigSnapshot(state, { + config: { gateway: { mode: "local" } }, + valid: true, + issues: [], + raw: "{}", + }); + + expect(state.configForm).toEqual({ gateway: { mode: "local" } }); + }); }); describe("updateConfigFormValue", () => { diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index 1722695af..ab6668318 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -4,19 +4,6 @@ import type { ConfigSnapshot, ConfigUiHints, } from "../types"; -import { - defaultDiscordActions, - defaultSlackActions, - type DiscordActionForm, - type DiscordForm, - type DiscordGuildChannelForm, - type DiscordGuildForm, - type IMessageForm, - type SlackChannelForm, - type SlackForm, - type SignalForm, - type TelegramForm, -} from "../ui-types"; import { cloneConfigObject, removePathValue, @@ -44,16 +31,6 @@ export type ConfigState = { configFormDirty: boolean; configFormMode: "form" | "raw"; lastError: string | null; - telegramForm: TelegramForm; - discordForm: DiscordForm; - slackForm: SlackForm; - signalForm: SignalForm; - imessageForm: IMessageForm; - telegramConfigStatus: string | null; - discordConfigStatus: string | null; - slackConfigStatus: string | null; - signalConfigStatus: string | null; - imessageConfigStatus: string | null; }; export async function loadConfig(state: ConfigState) { @@ -114,285 +91,6 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null; state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : []; - const config = snapshot.config ?? {}; - const channels = (config.channels ?? {}) as Record; - const telegram = (channels.telegram ?? config.telegram ?? {}) as Record; - const discord = (channels.discord ?? config.discord ?? {}) as Record; - const slack = (channels.slack ?? config.slack ?? {}) as Record; - const signal = (channels.signal ?? config.signal ?? {}) as Record; - const imessage = (channels.imessage ?? config.imessage ?? {}) as Record; - const toList = (value: unknown) => - Array.isArray(value) - ? value - .map((v) => String(v ?? "").trim()) - .filter((v) => v.length > 0) - .join(", ") - : ""; - const telegramGroups = - telegram.groups && typeof telegram.groups === "object" - ? (telegram.groups as Record) - : {}; - const telegramDefaultGroup = - telegramGroups["*"] && typeof telegramGroups["*"] === "object" - ? (telegramGroups["*"] as Record) - : {}; - const telegramHasWildcard = Boolean(telegramGroups["*"]); - const allowFrom = Array.isArray(telegram.allowFrom) - ? toList(telegram.allowFrom) - : typeof telegram.allowFrom === "string" - ? telegram.allowFrom - : ""; - - state.telegramForm = { - token: typeof telegram.botToken === "string" ? telegram.botToken : "", - requireMention: - typeof telegramDefaultGroup.requireMention === "boolean" - ? telegramDefaultGroup.requireMention - : true, - groupsWildcardEnabled: telegramHasWildcard, - allowFrom, - proxy: typeof telegram.proxy === "string" ? telegram.proxy : "", - webhookUrl: typeof telegram.webhookUrl === "string" ? telegram.webhookUrl : "", - webhookSecret: - typeof telegram.webhookSecret === "string" ? telegram.webhookSecret : "", - webhookPath: typeof telegram.webhookPath === "string" ? telegram.webhookPath : "", - }; - - const discordDm = (discord.dm ?? {}) as Record; - const slash = (discord.slashCommand ?? {}) as Record; - const discordActions = (discord.actions ?? {}) as Record; - const discordGuilds = discord.guilds; - const readAction = (key: keyof DiscordActionForm) => - typeof discordActions[key] === "boolean" - ? (discordActions[key] as boolean) - : defaultDiscordActions[key]; - state.discordForm = { - enabled: typeof discord.enabled === "boolean" ? discord.enabled : true, - token: typeof discord.token === "string" ? discord.token : "", - dmEnabled: typeof discordDm.enabled === "boolean" ? discordDm.enabled : true, - allowFrom: toList(discordDm.allowFrom), - groupEnabled: - typeof discordDm.groupEnabled === "boolean" ? discordDm.groupEnabled : false, - groupChannels: toList(discordDm.groupChannels), - mediaMaxMb: - typeof discord.mediaMaxMb === "number" ? String(discord.mediaMaxMb) : "", - historyLimit: - typeof discord.historyLimit === "number" ? String(discord.historyLimit) : "", - textChunkLimit: - typeof discord.textChunkLimit === "number" - ? String(discord.textChunkLimit) - : "", - replyToMode: - discord.replyToMode === "first" || discord.replyToMode === "all" - ? discord.replyToMode - : "off", - guilds: Array.isArray(discordGuilds) - ? [] - : typeof discordGuilds === "object" && discordGuilds - ? Object.entries(discordGuilds as Record).map( - ([key, value]): DiscordGuildForm => { - const entry = - value && typeof value === "object" - ? (value as Record) - : {}; - const channelsRaw = - entry.channels && typeof entry.channels === "object" - ? (entry.channels as Record) - : {}; - const channels = Object.entries(channelsRaw).map( - ([channelKey, channelValue]): DiscordGuildChannelForm => { - const channel = - channelValue && typeof channelValue === "object" - ? (channelValue as Record) - : {}; - return { - key: channelKey, - allow: - typeof channel.allow === "boolean" ? channel.allow : true, - requireMention: - typeof channel.requireMention === "boolean" - ? channel.requireMention - : false, - }; - }, - ); - return { - key, - slug: typeof entry.slug === "string" ? entry.slug : "", - requireMention: - typeof entry.requireMention === "boolean" - ? entry.requireMention - : false, - reactionNotifications: - entry.reactionNotifications === "off" || - entry.reactionNotifications === "all" || - entry.reactionNotifications === "own" || - entry.reactionNotifications === "allowlist" - ? entry.reactionNotifications - : "own", - users: toList(entry.users), - channels, - }; - }, - ) - : [], - actions: { - reactions: readAction("reactions"), - stickers: readAction("stickers"), - polls: readAction("polls"), - permissions: readAction("permissions"), - messages: readAction("messages"), - threads: readAction("threads"), - pins: readAction("pins"), - search: readAction("search"), - memberInfo: readAction("memberInfo"), - roleInfo: readAction("roleInfo"), - channelInfo: readAction("channelInfo"), - voiceStatus: readAction("voiceStatus"), - events: readAction("events"), - roles: readAction("roles"), - moderation: readAction("moderation"), - }, - slashEnabled: typeof slash.enabled === "boolean" ? slash.enabled : false, - slashName: typeof slash.name === "string" ? slash.name : "", - slashSessionPrefix: - typeof slash.sessionPrefix === "string" ? slash.sessionPrefix : "", - slashEphemeral: - typeof slash.ephemeral === "boolean" ? slash.ephemeral : true, - }; - - const slackDm = (slack.dm ?? {}) as Record; - const slackChannels = slack.channels; - const slackSlash = (slack.slashCommand ?? {}) as Record; - const slackActions = - (slack.actions ?? {}) as Partial>; - state.slackForm = { - enabled: typeof slack.enabled === "boolean" ? slack.enabled : true, - botToken: typeof slack.botToken === "string" ? slack.botToken : "", - appToken: typeof slack.appToken === "string" ? slack.appToken : "", - dmEnabled: typeof slackDm.enabled === "boolean" ? slackDm.enabled : true, - allowFrom: toList(slackDm.allowFrom), - groupEnabled: - typeof slackDm.groupEnabled === "boolean" ? slackDm.groupEnabled : false, - groupChannels: toList(slackDm.groupChannels), - mediaMaxMb: - typeof slack.mediaMaxMb === "number" ? String(slack.mediaMaxMb) : "", - textChunkLimit: - typeof slack.textChunkLimit === "number" - ? String(slack.textChunkLimit) - : "", - reactionNotifications: - slack.reactionNotifications === "off" || - slack.reactionNotifications === "all" || - slack.reactionNotifications === "allowlist" - ? slack.reactionNotifications - : "own", - reactionAllowlist: toList(slack.reactionAllowlist), - slashEnabled: - typeof slackSlash.enabled === "boolean" ? slackSlash.enabled : false, - slashName: typeof slackSlash.name === "string" ? slackSlash.name : "", - slashSessionPrefix: - typeof slackSlash.sessionPrefix === "string" - ? slackSlash.sessionPrefix - : "", - slashEphemeral: - typeof slackSlash.ephemeral === "boolean" ? slackSlash.ephemeral : true, - actions: { - ...defaultSlackActions, - reactions: - typeof slackActions.reactions === "boolean" - ? slackActions.reactions - : defaultSlackActions.reactions, - messages: - typeof slackActions.messages === "boolean" - ? slackActions.messages - : defaultSlackActions.messages, - pins: - typeof slackActions.pins === "boolean" - ? slackActions.pins - : defaultSlackActions.pins, - memberInfo: - typeof slackActions.memberInfo === "boolean" - ? slackActions.memberInfo - : defaultSlackActions.memberInfo, - emojiList: - typeof slackActions.emojiList === "boolean" - ? slackActions.emojiList - : defaultSlackActions.emojiList, - }, - channels: Array.isArray(slackChannels) - ? [] - : typeof slackChannels === "object" && slackChannels - ? Object.entries(slackChannels as Record).map( - ([key, value]): SlackChannelForm => { - const entry = - value && typeof value === "object" - ? (value as Record) - : {}; - return { - key, - allow: - typeof entry.allow === "boolean" ? entry.allow : true, - requireMention: - typeof entry.requireMention === "boolean" - ? entry.requireMention - : false, - }; - }, - ) - : [], - }; - - state.signalForm = { - enabled: typeof signal.enabled === "boolean" ? signal.enabled : true, - account: typeof signal.account === "string" ? signal.account : "", - httpUrl: typeof signal.httpUrl === "string" ? signal.httpUrl : "", - httpHost: typeof signal.httpHost === "string" ? signal.httpHost : "", - httpPort: typeof signal.httpPort === "number" ? String(signal.httpPort) : "", - cliPath: typeof signal.cliPath === "string" ? signal.cliPath : "", - autoStart: typeof signal.autoStart === "boolean" ? signal.autoStart : true, - receiveMode: - signal.receiveMode === "on-start" || signal.receiveMode === "manual" - ? signal.receiveMode - : "", - ignoreAttachments: - typeof signal.ignoreAttachments === "boolean" ? signal.ignoreAttachments : false, - ignoreStories: - typeof signal.ignoreStories === "boolean" ? signal.ignoreStories : false, - sendReadReceipts: - typeof signal.sendReadReceipts === "boolean" ? signal.sendReadReceipts : false, - allowFrom: toList(signal.allowFrom), - mediaMaxMb: - typeof signal.mediaMaxMb === "number" ? String(signal.mediaMaxMb) : "", - }; - - state.imessageForm = { - enabled: typeof imessage.enabled === "boolean" ? imessage.enabled : true, - cliPath: typeof imessage.cliPath === "string" ? imessage.cliPath : "", - dbPath: typeof imessage.dbPath === "string" ? imessage.dbPath : "", - service: - imessage.service === "imessage" || - imessage.service === "sms" || - imessage.service === "auto" - ? imessage.service - : "auto", - region: typeof imessage.region === "string" ? imessage.region : "", - allowFrom: toList(imessage.allowFrom), - includeAttachments: - typeof imessage.includeAttachments === "boolean" - ? imessage.includeAttachments - : false, - mediaMaxMb: - typeof imessage.mediaMaxMb === "number" ? String(imessage.mediaMaxMb) : "", - }; - - const configInvalid = snapshot.valid === false ? "Config invalid." : null; - state.telegramConfigStatus = configInvalid; - state.discordConfigStatus = configInvalid; - state.slackConfigStatus = configInvalid; - state.signalConfigStatus = configInvalid; - state.imessageConfigStatus = configInvalid; - if (!state.configFormDirty) { state.configForm = cloneConfigObject(snapshot.config ?? {}); } diff --git a/ui/src/ui/controllers/connections.save-discord.ts b/ui/src/ui/controllers/connections.save-discord.ts deleted file mode 100644 index 36a9b17ff..000000000 --- a/ui/src/ui/controllers/connections.save-discord.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { parseList } from "../format"; -import { - defaultDiscordActions, - type DiscordActionForm, - type DiscordGuildChannelForm, - type DiscordGuildForm, -} from "../ui-types"; -import type { ConnectionsState } from "./connections.types"; - -export async function saveDiscordConfig(state: ConnectionsState) { - if (!state.client || !state.connected) return; - if (state.discordSaving) return; - state.discordSaving = true; - state.discordConfigStatus = null; - try { - const baseHash = state.configSnapshot?.hash; - if (!baseHash) { - state.discordConfigStatus = "Config hash missing; reload and retry."; - return; - } - const discord: Record = {}; - const form = state.discordForm; - - if (form.enabled) { - discord.enabled = null; - } else { - discord.enabled = false; - } - - if (!state.discordTokenLocked) { - const token = form.token.trim(); - discord.token = token || null; - } - - const allowFrom = parseList(form.allowFrom); - const groupChannels = parseList(form.groupChannels); - const dm: Record = { - enabled: form.dmEnabled ? null : false, - allowFrom: allowFrom.length > 0 ? allowFrom : null, - groupEnabled: form.groupEnabled ? true : null, - groupChannels: groupChannels.length > 0 ? groupChannels : null, - }; - discord.dm = dm; - - const mediaMaxMb = Number(form.mediaMaxMb); - if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { - discord.mediaMaxMb = mediaMaxMb; - } else { - discord.mediaMaxMb = null; - } - - const historyLimitRaw = form.historyLimit.trim(); - if (historyLimitRaw.length === 0) { - discord.historyLimit = null; - } else { - const historyLimit = Number(historyLimitRaw); - if (Number.isFinite(historyLimit) && historyLimit >= 0) { - discord.historyLimit = historyLimit; - } else { - discord.historyLimit = null; - } - } - - const chunkLimitRaw = form.textChunkLimit.trim(); - if (chunkLimitRaw.length === 0) { - discord.textChunkLimit = null; - } else { - const chunkLimit = Number(chunkLimitRaw); - if (Number.isFinite(chunkLimit) && chunkLimit > 0) { - discord.textChunkLimit = chunkLimit; - } else { - discord.textChunkLimit = null; - } - } - - if (form.replyToMode === "off") { - discord.replyToMode = null; - } else { - discord.replyToMode = form.replyToMode; - } - - const guildsForm = Array.isArray(form.guilds) ? form.guilds : []; - const guilds: Record = {}; - guildsForm.forEach((guild: DiscordGuildForm) => { - const key = String(guild.key ?? "").trim(); - if (!key) return; - const entry: Record = {}; - const slug = String(guild.slug ?? "").trim(); - if (slug) entry.slug = slug; - if (guild.requireMention) entry.requireMention = true; - if ( - guild.reactionNotifications === "off" || - guild.reactionNotifications === "all" || - guild.reactionNotifications === "own" || - guild.reactionNotifications === "allowlist" - ) { - entry.reactionNotifications = guild.reactionNotifications; - } - const users = parseList(guild.users); - if (users.length > 0) entry.users = users; - const channels: Record = {}; - const channelForms = Array.isArray(guild.channels) ? guild.channels : []; - channelForms.forEach((channel: DiscordGuildChannelForm) => { - const channelKey = String(channel.key ?? "").trim(); - if (!channelKey) return; - const channelEntry: Record = {}; - if (channel.allow === false) channelEntry.allow = false; - if (channel.requireMention) channelEntry.requireMention = true; - channels[channelKey] = channelEntry; - }); - if (Object.keys(channels).length > 0) entry.channels = channels; - guilds[key] = entry; - }); - if (Object.keys(guilds).length > 0) discord.guilds = guilds; - else discord.guilds = null; - - const actions: Partial = {}; - const applyAction = (key: keyof DiscordActionForm) => { - const value = form.actions[key]; - if (value !== defaultDiscordActions[key]) actions[key] = value; - }; - applyAction("reactions"); - applyAction("stickers"); - applyAction("polls"); - applyAction("permissions"); - applyAction("messages"); - applyAction("threads"); - applyAction("pins"); - applyAction("search"); - applyAction("memberInfo"); - applyAction("roleInfo"); - applyAction("channelInfo"); - applyAction("voiceStatus"); - applyAction("events"); - applyAction("roles"); - applyAction("moderation"); - if (Object.keys(actions).length > 0) { - discord.actions = actions; - } else { - discord.actions = null; - } - - const slash = { ...(discord.slashCommand ?? {}) } as Record; - if (form.slashEnabled) { - slash.enabled = true; - } else { - slash.enabled = null; - } - if (form.slashName.trim()) slash.name = form.slashName.trim(); - else slash.name = null; - if (form.slashSessionPrefix.trim()) - slash.sessionPrefix = form.slashSessionPrefix.trim(); - else slash.sessionPrefix = null; - if (form.slashEphemeral) { - slash.ephemeral = null; - } else { - slash.ephemeral = false; - } - discord.slashCommand = Object.keys(slash).length > 0 ? slash : null; - - const raw = `${JSON.stringify( - { channels: { discord } }, - null, - 2, - ).trimEnd()}\n`; - await state.client.request("config.patch", { raw, baseHash }); - state.discordConfigStatus = "Saved. Restart gateway if needed."; - } catch (err) { - state.discordConfigStatus = String(err); - } finally { - state.discordSaving = false; - } -} diff --git a/ui/src/ui/controllers/connections.save-imessage.ts b/ui/src/ui/controllers/connections.save-imessage.ts deleted file mode 100644 index a8dae50cf..000000000 --- a/ui/src/ui/controllers/connections.save-imessage.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { parseList } from "../format"; -import type { ConnectionsState } from "./connections.types"; - -export async function saveIMessageConfig(state: ConnectionsState) { - if (!state.client || !state.connected) return; - if (state.imessageSaving) return; - state.imessageSaving = true; - state.imessageConfigStatus = null; - try { - const baseHash = state.configSnapshot?.hash; - if (!baseHash) { - state.imessageConfigStatus = "Config hash missing; reload and retry."; - return; - } - const imessage: Record = {}; - const form = state.imessageForm; - - if (form.enabled) { - imessage.enabled = null; - } else { - imessage.enabled = false; - } - - const cliPath = form.cliPath.trim(); - imessage.cliPath = cliPath || null; - - const dbPath = form.dbPath.trim(); - imessage.dbPath = dbPath || null; - - if (form.service === "auto") { - imessage.service = null; - } else { - imessage.service = form.service; - } - - const region = form.region.trim(); - imessage.region = region || null; - - const allowFrom = parseList(form.allowFrom); - imessage.allowFrom = allowFrom.length > 0 ? allowFrom : null; - - imessage.includeAttachments = form.includeAttachments ? true : null; - - const mediaMaxMb = Number(form.mediaMaxMb); - if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { - imessage.mediaMaxMb = mediaMaxMb; - } else { - imessage.mediaMaxMb = null; - } - - const raw = `${JSON.stringify( - { channels: { imessage } }, - null, - 2, - ).trimEnd()}\n`; - await state.client.request("config.patch", { raw, baseHash }); - state.imessageConfigStatus = "Saved. Restart gateway if needed."; - } catch (err) { - state.imessageConfigStatus = String(err); - } finally { - state.imessageSaving = false; - } -} diff --git a/ui/src/ui/controllers/connections.save-signal.ts b/ui/src/ui/controllers/connections.save-signal.ts deleted file mode 100644 index 57fccbe36..000000000 --- a/ui/src/ui/controllers/connections.save-signal.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { parseList } from "../format"; -import type { ConnectionsState } from "./connections.types"; - -export async function saveSignalConfig(state: ConnectionsState) { - if (!state.client || !state.connected) return; - if (state.signalSaving) return; - state.signalSaving = true; - state.signalConfigStatus = null; - try { - const baseHash = state.configSnapshot?.hash; - if (!baseHash) { - state.signalConfigStatus = "Config hash missing; reload and retry."; - return; - } - const signal: Record = {}; - const form = state.signalForm; - - if (form.enabled) { - signal.enabled = null; - } else { - signal.enabled = false; - } - - const account = form.account.trim(); - signal.account = account || null; - - const httpUrl = form.httpUrl.trim(); - signal.httpUrl = httpUrl || null; - - const httpHost = form.httpHost.trim(); - signal.httpHost = httpHost || null; - - const httpPort = Number(form.httpPort); - if (Number.isFinite(httpPort) && httpPort > 0) { - signal.httpPort = httpPort; - } else { - signal.httpPort = null; - } - - const cliPath = form.cliPath.trim(); - signal.cliPath = cliPath || null; - - if (form.autoStart) { - signal.autoStart = null; - } else { - signal.autoStart = false; - } - - if (form.receiveMode === "on-start" || form.receiveMode === "manual") { - signal.receiveMode = form.receiveMode; - } else { - signal.receiveMode = null; - } - - signal.ignoreAttachments = form.ignoreAttachments ? true : null; - signal.ignoreStories = form.ignoreStories ? true : null; - signal.sendReadReceipts = form.sendReadReceipts ? true : null; - - const allowFrom = parseList(form.allowFrom); - signal.allowFrom = allowFrom.length > 0 ? allowFrom : null; - - const mediaMaxMb = Number(form.mediaMaxMb); - if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { - signal.mediaMaxMb = mediaMaxMb; - } else { - signal.mediaMaxMb = null; - } - - const raw = `${JSON.stringify( - { channels: { signal } }, - null, - 2, - ).trimEnd()}\n`; - await state.client.request("config.patch", { raw, baseHash }); - state.signalConfigStatus = "Saved. Restart gateway if needed."; - } catch (err) { - state.signalConfigStatus = String(err); - } finally { - state.signalSaving = false; - } -} diff --git a/ui/src/ui/controllers/connections.save-slack.ts b/ui/src/ui/controllers/connections.save-slack.ts deleted file mode 100644 index 5e020119f..000000000 --- a/ui/src/ui/controllers/connections.save-slack.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { parseList } from "../format"; -import { defaultSlackActions, type SlackActionForm } from "../ui-types"; -import type { ConnectionsState } from "./connections.types"; - -export async function saveSlackConfig(state: ConnectionsState) { - if (!state.client || !state.connected) return; - if (state.slackSaving) return; - state.slackSaving = true; - state.slackConfigStatus = null; - try { - const baseHash = state.configSnapshot?.hash; - if (!baseHash) { - state.slackConfigStatus = "Config hash missing; reload and retry."; - return; - } - const slack: Record = {}; - const form = state.slackForm; - - if (form.enabled) { - slack.enabled = null; - } else { - slack.enabled = false; - } - - if (!state.slackTokenLocked) { - const token = form.botToken.trim(); - slack.botToken = token || null; - } - if (!state.slackAppTokenLocked) { - const token = form.appToken.trim(); - slack.appToken = token || null; - } - - const dm: Record = {}; - dm.enabled = form.dmEnabled; - const allowFrom = parseList(form.allowFrom); - dm.allowFrom = allowFrom.length > 0 ? allowFrom : null; - if (form.groupEnabled) { - dm.groupEnabled = true; - } else { - dm.groupEnabled = null; - } - const groupChannels = parseList(form.groupChannels); - dm.groupChannels = groupChannels.length > 0 ? groupChannels : null; - slack.dm = dm; - - const mediaMaxMb = Number.parseFloat(form.mediaMaxMb); - if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { - slack.mediaMaxMb = mediaMaxMb; - } else { - slack.mediaMaxMb = null; - } - - const textChunkLimit = Number.parseInt(form.textChunkLimit, 10); - if (Number.isFinite(textChunkLimit) && textChunkLimit > 0) { - slack.textChunkLimit = textChunkLimit; - } else { - slack.textChunkLimit = null; - } - - if (form.reactionNotifications === "own") { - slack.reactionNotifications = null; - } else { - slack.reactionNotifications = form.reactionNotifications; - } - const reactionAllowlist = parseList(form.reactionAllowlist); - if (reactionAllowlist.length > 0) { - slack.reactionAllowlist = reactionAllowlist; - } else { - slack.reactionAllowlist = null; - } - - const slash: Record = {}; - if (form.slashEnabled) { - slash.enabled = true; - } else { - slash.enabled = null; - } - if (form.slashName.trim()) slash.name = form.slashName.trim(); - else slash.name = null; - if (form.slashSessionPrefix.trim()) - slash.sessionPrefix = form.slashSessionPrefix.trim(); - else slash.sessionPrefix = null; - if (form.slashEphemeral) { - slash.ephemeral = null; - } else { - slash.ephemeral = false; - } - slack.slashCommand = slash; - - const actions: Partial = {}; - const applyAction = (key: keyof SlackActionForm) => { - const value = form.actions[key]; - if (value !== defaultSlackActions[key]) actions[key] = value; - }; - applyAction("reactions"); - applyAction("messages"); - applyAction("pins"); - applyAction("memberInfo"); - applyAction("emojiList"); - if (Object.keys(actions).length > 0) { - slack.actions = actions; - } else { - slack.actions = null; - } - - const channels = form.channels - .map((entry): [string, Record] | null => { - const key = entry.key.trim(); - if (!key) return null; - const record: Record = { - allow: entry.allow, - requireMention: entry.requireMention, - }; - return [key, record]; - }) - .filter((value): value is [string, Record] => - Boolean(value), - ); - if (channels.length > 0) { - slack.channels = Object.fromEntries(channels); - } else { - slack.channels = null; - } - - const raw = `${JSON.stringify( - { channels: { slack } }, - null, - 2, - ).trimEnd()}\n`; - await state.client.request("config.patch", { raw, baseHash }); - state.slackConfigStatus = "Saved. Restart gateway if needed."; - } catch (err) { - state.slackConfigStatus = String(err); - } finally { - state.slackSaving = false; - } -} diff --git a/ui/src/ui/controllers/connections.ts b/ui/src/ui/controllers/connections.ts deleted file mode 100644 index 467f5062f..000000000 --- a/ui/src/ui/controllers/connections.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { parseList } from "../format"; -import type { ChannelsStatusSnapshot } from "../types"; -import { - type DiscordForm, - type IMessageForm, - type SlackForm, - type SignalForm, - type TelegramForm, -} from "../ui-types"; -import type { ConnectionsState } from "./connections.types"; - -export { saveDiscordConfig } from "./connections.save-discord"; -export { saveIMessageConfig } from "./connections.save-imessage"; -export { saveSlackConfig } from "./connections.save-slack"; -export { saveSignalConfig } from "./connections.save-signal"; - -export type { ConnectionsState }; - -export async function loadChannels(state: ConnectionsState, probe: boolean) { - if (!state.client || !state.connected) return; - if (state.channelsLoading) return; - state.channelsLoading = true; - state.channelsError = null; - try { - const res = (await state.client.request("channels.status", { - probe, - timeoutMs: 8000, - })) as ChannelsStatusSnapshot; - state.channelsSnapshot = res; - state.channelsLastSuccess = Date.now(); - const channels = res.channels as Record; - const telegram = channels.telegram as { tokenSource?: string | null }; - const discord = channels.discord as { tokenSource?: string | null } | null; - const slack = channels.slack as - | { botTokenSource?: string | null; appTokenSource?: string | null } - | null; - state.telegramTokenLocked = telegram?.tokenSource === "env"; - state.discordTokenLocked = discord?.tokenSource === "env"; - state.slackTokenLocked = slack?.botTokenSource === "env"; - state.slackAppTokenLocked = slack?.appTokenSource === "env"; - } catch (err) { - state.channelsError = String(err); - } finally { - state.channelsLoading = false; - } -} - -export async function startWhatsAppLogin(state: ConnectionsState, force: boolean) { - if (!state.client || !state.connected || state.whatsappBusy) return; - state.whatsappBusy = true; - try { - const res = (await state.client.request("web.login.start", { - force, - timeoutMs: 30000, - })) as { message?: string; qrDataUrl?: string }; - state.whatsappLoginMessage = res.message ?? null; - state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null; - state.whatsappLoginConnected = null; - } catch (err) { - state.whatsappLoginMessage = String(err); - state.whatsappLoginQrDataUrl = null; - state.whatsappLoginConnected = null; - } finally { - state.whatsappBusy = false; - } -} - -export async function waitWhatsAppLogin(state: ConnectionsState) { - if (!state.client || !state.connected || state.whatsappBusy) return; - state.whatsappBusy = true; - try { - const res = (await state.client.request("web.login.wait", { - timeoutMs: 120000, - })) as { connected?: boolean; message?: string }; - state.whatsappLoginMessage = res.message ?? null; - state.whatsappLoginConnected = res.connected ?? null; - if (res.connected) state.whatsappLoginQrDataUrl = null; - } catch (err) { - state.whatsappLoginMessage = String(err); - state.whatsappLoginConnected = null; - } finally { - state.whatsappBusy = false; - } -} - -export async function logoutWhatsApp(state: ConnectionsState) { - if (!state.client || !state.connected || state.whatsappBusy) return; - state.whatsappBusy = true; - try { - await state.client.request("channels.logout", { channel: "whatsapp" }); - state.whatsappLoginMessage = "Logged out."; - state.whatsappLoginQrDataUrl = null; - state.whatsappLoginConnected = null; - } catch (err) { - state.whatsappLoginMessage = String(err); - } finally { - state.whatsappBusy = false; - } -} - -export function updateTelegramForm( - state: ConnectionsState, - patch: Partial, -) { - state.telegramForm = { ...state.telegramForm, ...patch }; -} - -export function updateDiscordForm( - state: ConnectionsState, - patch: Partial, -) { - if (patch.actions) { - state.discordForm = { - ...state.discordForm, - ...patch, - actions: { ...state.discordForm.actions, ...patch.actions }, - }; - return; - } - state.discordForm = { ...state.discordForm, ...patch }; -} - -export function updateSlackForm( - state: ConnectionsState, - patch: Partial, -) { - if (patch.actions) { - state.slackForm = { - ...state.slackForm, - ...patch, - actions: { ...state.slackForm.actions, ...patch.actions }, - }; - return; - } - state.slackForm = { ...state.slackForm, ...patch }; -} - -export function updateSignalForm( - state: ConnectionsState, - patch: Partial, -) { - state.signalForm = { ...state.signalForm, ...patch }; -} - -export function updateIMessageForm( - state: ConnectionsState, - patch: Partial, -) { - state.imessageForm = { ...state.imessageForm, ...patch }; -} - -export async function saveTelegramConfig(state: ConnectionsState) { - if (!state.client || !state.connected) return; - if (state.telegramSaving) return; - state.telegramSaving = true; - state.telegramConfigStatus = null; - try { - if (state.telegramForm.groupsWildcardEnabled) { - const confirmed = window.confirm( - 'Telegram groups wildcard "*" allows all groups. Continue?', - ); - if (!confirmed) { - state.telegramConfigStatus = "Save cancelled."; - return; - } - } - const base = state.configSnapshot?.config ?? {}; - const channels = (base.channels ?? {}) as Record; - const telegram = { - ...(channels.telegram ?? base.telegram ?? {}), - } as Record; - if (!state.telegramTokenLocked) { - const token = state.telegramForm.token.trim(); - telegram.botToken = token || null; - } - const groupsPatch: Record = {}; - if (state.telegramForm.groupsWildcardEnabled) { - const existingGroups = telegram.groups as Record | undefined; - const defaultGroup = - existingGroups?.["*"] && typeof existingGroups["*"] === "object" - ? ({ ...(existingGroups["*"] as Record) } as Record< - string, - unknown - >) - : {}; - defaultGroup.requireMention = state.telegramForm.requireMention; - groupsPatch["*"] = defaultGroup; - } else { - groupsPatch["*"] = null; - } - telegram.groups = groupsPatch; - telegram.requireMention = null; - const allowFrom = parseList(state.telegramForm.allowFrom); - telegram.allowFrom = allowFrom.length > 0 ? allowFrom : null; - const proxy = state.telegramForm.proxy.trim(); - telegram.proxy = proxy || null; - const webhookUrl = state.telegramForm.webhookUrl.trim(); - telegram.webhookUrl = webhookUrl || null; - const webhookSecret = state.telegramForm.webhookSecret.trim(); - telegram.webhookSecret = webhookSecret || null; - const webhookPath = state.telegramForm.webhookPath.trim(); - telegram.webhookPath = webhookPath || null; - - const baseHash = state.configSnapshot?.hash; - if (!baseHash) { - state.telegramConfigStatus = "Config hash missing; reload and retry."; - return; - } - const raw = `${JSON.stringify( - { channels: { telegram } }, - null, - 2, - ).trimEnd()}\n`; - await state.client.request("config.patch", { raw, baseHash }); - state.telegramConfigStatus = "Saved. Restart gateway if needed."; - } catch (err) { - state.telegramConfigStatus = String(err); - } finally { - state.telegramSaving = false; - } -} diff --git a/ui/src/ui/controllers/connections.types.ts b/ui/src/ui/controllers/connections.types.ts deleted file mode 100644 index d01bb428c..000000000 --- a/ui/src/ui/controllers/connections.types.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { GatewayBrowserClient } from "../gateway"; -import type { ChannelsStatusSnapshot, ConfigSnapshot } from "../types"; -import type { - DiscordForm, - IMessageForm, - SlackForm, - SignalForm, - TelegramForm, -} from "../ui-types"; - -export type ConnectionsState = { - client: GatewayBrowserClient | null; - connected: boolean; - channelsLoading: boolean; - channelsSnapshot: ChannelsStatusSnapshot | null; - channelsError: string | null; - channelsLastSuccess: number | null; - whatsappLoginMessage: string | null; - whatsappLoginQrDataUrl: string | null; - whatsappLoginConnected: boolean | null; - whatsappBusy: boolean; - telegramForm: TelegramForm; - telegramSaving: boolean; - telegramTokenLocked: boolean; - telegramConfigStatus: string | null; - discordForm: DiscordForm; - discordSaving: boolean; - discordTokenLocked: boolean; - discordConfigStatus: string | null; - slackForm: SlackForm; - slackSaving: boolean; - slackTokenLocked: boolean; - slackAppTokenLocked: boolean; - slackConfigStatus: string | null; - signalForm: SignalForm; - signalSaving: boolean; - signalConfigStatus: string | null; - imessageForm: IMessageForm; - imessageSaving: boolean; - imessageConfigStatus: string | null; - configSnapshot: ConfigSnapshot | null; -}; - diff --git a/ui/src/ui/focus-mode.browser.test.ts b/ui/src/ui/focus-mode.browser.test.ts index 3c3e0b79c..ba81456bc 100644 --- a/ui/src/ui/focus-mode.browser.test.ts +++ b/ui/src/ui/focus-mode.browser.test.ts @@ -45,14 +45,14 @@ describe("chat focus mode", () => { await app.updateComplete; expect(shell?.classList.contains("shell--chat-focus")).toBe(true); - const link = app.querySelector('a.nav-item[href="/connections"]'); + const link = app.querySelector('a.nav-item[href="/channels"]'); expect(link).not.toBeNull(); link?.dispatchEvent( new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }), ); await app.updateComplete; - expect(app.tab).toBe("connections"); + expect(app.tab).toBe("channels"); expect(shell?.classList.contains("shell--chat-focus")).toBe(false); const chatLink = app.querySelector('a.nav-item[href="/chat"]'); diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index c2f238d49..0139ec552 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -76,7 +76,7 @@ describe("control UI routing", () => { await app.updateComplete; const link = app.querySelector( - 'a.nav-item[href="/connections"]', + 'a.nav-item[href="/channels"]', ); expect(link).not.toBeNull(); link?.dispatchEvent( @@ -84,8 +84,8 @@ describe("control UI routing", () => { ); await app.updateComplete; - expect(app.tab).toBe("connections"); - expect(window.location.pathname).toBe("/connections"); + expect(app.tab).toBe("channels"); + expect(window.location.pathname).toBe("/channels"); }); it("keeps chat and nav usable on narrow viewports", async () => { diff --git a/ui/src/ui/navigation.test.ts b/ui/src/ui/navigation.test.ts index 9b46c8dce..168125d71 100644 --- a/ui/src/ui/navigation.test.ts +++ b/ui/src/ui/navigation.test.ts @@ -29,7 +29,7 @@ describe("iconForTab", () => { it("returns stable icons for known tabs", () => { expect(iconForTab("chat")).toBe("💬"); expect(iconForTab("overview")).toBe("📊"); - expect(iconForTab("connections")).toBe("🔗"); + expect(iconForTab("channels")).toBe("🔗"); expect(iconForTab("instances")).toBe("📡"); expect(iconForTab("sessions")).toBe("📄"); expect(iconForTab("cron")).toBe("⏰"); diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index 2cf714210..623764a8f 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -2,7 +2,7 @@ export const TAB_GROUPS = [ { label: "Chat", tabs: ["chat"] }, { label: "Control", - tabs: ["overview", "connections", "instances", "sessions", "cron"], + tabs: ["overview", "channels", "instances", "sessions", "cron"], }, { label: "Agent", tabs: ["skills", "nodes"] }, { label: "Settings", tabs: ["config", "debug", "logs"] }, @@ -10,7 +10,7 @@ export const TAB_GROUPS = [ export type Tab = | "overview" - | "connections" + | "channels" | "instances" | "sessions" | "cron" @@ -23,7 +23,7 @@ export type Tab = const TAB_PATHS: Record = { overview: "/overview", - connections: "/connections", + channels: "/channels", instances: "/instances", sessions: "/sessions", cron: "/cron", @@ -104,7 +104,7 @@ export function iconForTab(tab: Tab): string { return "💬"; case "overview": return "📊"; - case "connections": + case "channels": return "🔗"; case "instances": return "📡"; @@ -131,8 +131,8 @@ export function titleForTab(tab: Tab) { switch (tab) { case "overview": return "Overview"; - case "connections": - return "Connections"; + case "channels": + return "Channels"; case "instances": return "Instances"; case "sessions": @@ -160,8 +160,8 @@ export function subtitleForTab(tab: Tab) { switch (tab) { case "overview": return "Gateway status, entry points, and a fast health read."; - case "connections": - return "Link channels and keep transport settings in sync."; + case "channels": + return "Manage channels and settings."; case "instances": return "Presence beacons from connected clients and nodes."; case "sessions": diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 51c9b7cdf..3695fee6b 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -1,159 +1,9 @@ -export type TelegramForm = { - token: string; - requireMention: boolean; - groupsWildcardEnabled: boolean; - allowFrom: string; - proxy: string; - webhookUrl: string; - webhookSecret: string; - webhookPath: string; -}; - export type ChatQueueItem = { id: string; text: string; createdAt: number; }; -export type DiscordForm = { - enabled: boolean; - token: string; - dmEnabled: boolean; - allowFrom: string; - groupEnabled: boolean; - groupChannels: string; - mediaMaxMb: string; - historyLimit: string; - textChunkLimit: string; - replyToMode: "off" | "first" | "all"; - guilds: DiscordGuildForm[]; - actions: DiscordActionForm; - slashEnabled: boolean; - slashName: string; - slashSessionPrefix: string; - slashEphemeral: boolean; -}; - -export type DiscordGuildForm = { - key: string; - slug: string; - requireMention: boolean; - reactionNotifications: "off" | "own" | "all" | "allowlist"; - users: string; - channels: DiscordGuildChannelForm[]; -}; - -export type DiscordGuildChannelForm = { - key: string; - allow: boolean; - requireMention: boolean; -}; - -export type DiscordActionForm = { - reactions: boolean; - stickers: boolean; - polls: boolean; - permissions: boolean; - messages: boolean; - threads: boolean; - pins: boolean; - search: boolean; - memberInfo: boolean; - roleInfo: boolean; - channelInfo: boolean; - voiceStatus: boolean; - events: boolean; - roles: boolean; - moderation: boolean; -}; - -export type SlackChannelForm = { - key: string; - allow: boolean; - requireMention: boolean; -}; - -export type SlackActionForm = { - reactions: boolean; - messages: boolean; - pins: boolean; - memberInfo: boolean; - emojiList: boolean; -}; - -export type SlackForm = { - enabled: boolean; - botToken: string; - appToken: string; - dmEnabled: boolean; - allowFrom: string; - groupEnabled: boolean; - groupChannels: string; - mediaMaxMb: string; - textChunkLimit: string; - reactionNotifications: "off" | "own" | "all" | "allowlist"; - reactionAllowlist: string; - slashEnabled: boolean; - slashName: string; - slashSessionPrefix: string; - slashEphemeral: boolean; - actions: SlackActionForm; - channels: SlackChannelForm[]; -}; - -export const defaultDiscordActions: DiscordActionForm = { - reactions: true, - stickers: true, - polls: true, - permissions: true, - messages: true, - threads: true, - pins: true, - search: true, - memberInfo: true, - roleInfo: true, - channelInfo: true, - voiceStatus: true, - events: true, - roles: false, - moderation: false, -}; - -export const defaultSlackActions: SlackActionForm = { - reactions: true, - messages: true, - pins: true, - memberInfo: true, - emojiList: true, -}; - -export type SignalForm = { - enabled: boolean; - account: string; - httpUrl: string; - httpHost: string; - httpPort: string; - cliPath: string; - autoStart: boolean; - receiveMode: "on-start" | "manual" | ""; - ignoreAttachments: boolean; - ignoreStories: boolean; - sendReadReceipts: boolean; - allowFrom: string; - mediaMaxMb: string; -}; - -export type IMessageForm = { - enabled: boolean; - cliPath: string; - dbPath: string; - service: "auto" | "imessage" | "sms"; - region: string; - allowFrom: string; - includeAttachments: boolean; - mediaMaxMb: string; -}; - export type CronFormState = { name: string; description: string; diff --git a/ui/src/ui/views/channels.config.ts b/ui/src/ui/views/channels.config.ts new file mode 100644 index 000000000..3c4d2c7df --- /dev/null +++ b/ui/src/ui/views/channels.config.ts @@ -0,0 +1,134 @@ +import { html } from "lit"; + +import type { ConfigUiHints } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { + analyzeConfigSchema, + renderNode, + schemaType, + type JsonSchema, +} from "./config-form"; + +type ChannelConfigFormProps = { + channelId: string; + configValue: Record | null; + schema: unknown | null; + uiHints: ConfigUiHints; + disabled: boolean; + onPatch: (path: Array, value: unknown) => void; +}; + +function resolveSchemaNode( + schema: JsonSchema | null, + path: Array, +): JsonSchema | null { + let current = schema; + for (const key of path) { + if (!current) return null; + const type = schemaType(current); + if (type === "object") { + const properties = current.properties ?? {}; + if (typeof key === "string" && properties[key]) { + current = properties[key]; + continue; + } + const additional = current.additionalProperties; + if (typeof key === "string" && additional && typeof additional === "object") { + current = additional as JsonSchema; + continue; + } + return null; + } + if (type === "array") { + if (typeof key !== "number") return null; + const items = Array.isArray(current.items) ? current.items[0] : current.items; + current = items ?? null; + continue; + } + return null; + } + return current; +} + +function resolveChannelValue( + config: Record, + channelId: string, +): Record { + const channels = (config.channels ?? {}) as Record; + const fromChannels = channels[channelId]; + const fallback = config[channelId]; + const resolved = + (fromChannels && typeof fromChannels === "object" + ? (fromChannels as Record) + : null) ?? + (fallback && typeof fallback === "object" + ? (fallback as Record) + : null); + return resolved ?? {}; +} + +export function renderChannelConfigForm(props: ChannelConfigFormProps) { + const analysis = analyzeConfigSchema(props.schema); + const normalized = analysis.schema; + if (!normalized) { + return html`
Schema unavailable. Use Raw.
`; + } + const node = resolveSchemaNode(normalized, ["channels", props.channelId]); + if (!node) { + return html`
Channel config schema unavailable.
`; + } + const configValue = props.configValue ?? {}; + const value = resolveChannelValue(configValue, props.channelId); + return html` +
+ ${renderNode({ + schema: node, + value, + path: ["channels", props.channelId], + hints: props.uiHints, + unsupported: new Set(analysis.unsupportedPaths), + disabled: props.disabled, + showLabel: false, + onPatch: props.onPatch, + })} +
+ `; +} + +export function renderChannelConfigSection(params: { + channelId: string; + props: ChannelsProps; +}) { + const { channelId, props } = params; + const disabled = props.configSaving || props.configSchemaLoading; + return html` +
+ ${props.configSchemaLoading + ? html`
Loading config schema…
` + : renderChannelConfigForm({ + channelId, + configValue: props.configForm, + schema: props.configSchema, + uiHints: props.configUiHints, + disabled, + onPatch: props.onConfigPatch, + })} +
+ + +
+
+ `; +} diff --git a/ui/src/ui/views/channels.discord.ts b/ui/src/ui/views/channels.discord.ts new file mode 100644 index 000000000..07890e969 --- /dev/null +++ b/ui/src/ui/views/channels.discord.ts @@ -0,0 +1,62 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { DiscordStatus } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { renderChannelConfigSection } from "./channels.config"; + +export function renderDiscordCard(params: { + props: ChannelsProps; + discord?: DiscordStatus | null; + accountCountLabel: unknown; +}) { + const { props, discord, accountCountLabel } = params; + + return html` +
+
Discord
+
Bot status and channel configuration.
+ ${accountCountLabel} + +
+
+ Configured + ${discord?.configured ? "Yes" : "No"} +
+
+ Running + ${discord?.running ? "Yes" : "No"} +
+
+ Last start + ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"} +
+
+ Last probe + ${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"} +
+
+ + ${discord?.lastError + ? html`
+ ${discord.lastError} +
` + : nothing} + + ${discord?.probe + ? html`
+ Probe ${discord.probe.ok ? "ok" : "failed"} · + ${discord.probe.status ?? ""} ${discord.probe.error ?? ""} +
` + : nothing} + + ${renderChannelConfigSection({ channelId: "discord", props })} + +
+ +
+
+ `; +} diff --git a/ui/src/ui/views/channels.imessage.ts b/ui/src/ui/views/channels.imessage.ts new file mode 100644 index 000000000..85fd90d03 --- /dev/null +++ b/ui/src/ui/views/channels.imessage.ts @@ -0,0 +1,62 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { IMessageStatus } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { renderChannelConfigSection } from "./channels.config"; + +export function renderIMessageCard(params: { + props: ChannelsProps; + imessage?: IMessageStatus | null; + accountCountLabel: unknown; +}) { + const { props, imessage, accountCountLabel } = params; + + return html` +
+
iMessage
+
macOS bridge status and channel configuration.
+ ${accountCountLabel} + +
+
+ Configured + ${imessage?.configured ? "Yes" : "No"} +
+
+ Running + ${imessage?.running ? "Yes" : "No"} +
+
+ Last start + ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"} +
+
+ Last probe + ${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"} +
+
+ + ${imessage?.lastError + ? html`
+ ${imessage.lastError} +
` + : nothing} + + ${imessage?.probe + ? html`
+ Probe ${imessage.probe.ok ? "ok" : "failed"} · + ${imessage.probe.error ?? ""} +
` + : nothing} + + ${renderChannelConfigSection({ channelId: "imessage", props })} + +
+ +
+
+ `; +} diff --git a/ui/src/ui/views/channels.shared.ts b/ui/src/ui/views/channels.shared.ts new file mode 100644 index 000000000..6238a1e1c --- /dev/null +++ b/ui/src/ui/views/channels.shared.ts @@ -0,0 +1,46 @@ +import { html, nothing } from "lit"; + +import type { ChannelAccountSnapshot } from "../types"; +import type { ChannelKey, ChannelsProps } from "./channels.types"; + +export function formatDuration(ms?: number | null) { + if (!ms && ms !== 0) return "n/a"; + const sec = Math.round(ms / 1000); + if (sec < 60) return `${sec}s`; + const min = Math.round(sec / 60); + if (min < 60) return `${min}m`; + const hr = Math.round(min / 60); + return `${hr}h`; +} + +export function channelEnabled(key: ChannelKey, props: ChannelsProps) { + const snapshot = props.snapshot; + const channels = snapshot?.channels as Record | null; + if (!snapshot || !channels) return false; + const channelStatus = channels[key] as Record | undefined; + const configured = typeof channelStatus?.configured === "boolean" && channelStatus.configured; + const running = typeof channelStatus?.running === "boolean" && channelStatus.running; + const connected = typeof channelStatus?.connected === "boolean" && channelStatus.connected; + const accounts = snapshot.channelAccounts?.[key] ?? []; + const accountActive = accounts.some( + (account) => account.configured || account.running || account.connected, + ); + return configured || running || connected || accountActive; +} + +export function getChannelAccountCount( + key: ChannelKey, + channelAccounts?: Record | null, +): number { + return channelAccounts?.[key]?.length ?? 0; +} + +export function renderChannelAccountCount( + key: ChannelKey, + channelAccounts?: Record | null, +) { + const count = getChannelAccountCount(key, channelAccounts); + if (count < 2) return nothing; + return html``; +} + diff --git a/ui/src/ui/views/channels.signal.ts b/ui/src/ui/views/channels.signal.ts new file mode 100644 index 000000000..9d4f6c147 --- /dev/null +++ b/ui/src/ui/views/channels.signal.ts @@ -0,0 +1,66 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { SignalStatus } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { renderChannelConfigSection } from "./channels.config"; + +export function renderSignalCard(params: { + props: ChannelsProps; + signal?: SignalStatus | null; + accountCountLabel: unknown; +}) { + const { props, signal, accountCountLabel } = params; + + return html` +
+
Signal
+
signal-cli status and channel configuration.
+ ${accountCountLabel} + +
+
+ Configured + ${signal?.configured ? "Yes" : "No"} +
+
+ Running + ${signal?.running ? "Yes" : "No"} +
+
+ Base URL + ${signal?.baseUrl ?? "n/a"} +
+
+ Last start + ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"} +
+
+ Last probe + ${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"} +
+
+ + ${signal?.lastError + ? html`
+ ${signal.lastError} +
` + : nothing} + + ${signal?.probe + ? html`
+ Probe ${signal.probe.ok ? "ok" : "failed"} · + ${signal.probe.status ?? ""} ${signal.probe.error ?? ""} +
` + : nothing} + + ${renderChannelConfigSection({ channelId: "signal", props })} + +
+ +
+
+ `; +} diff --git a/ui/src/ui/views/channels.slack.ts b/ui/src/ui/views/channels.slack.ts new file mode 100644 index 000000000..eb93ac4c3 --- /dev/null +++ b/ui/src/ui/views/channels.slack.ts @@ -0,0 +1,62 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { SlackStatus } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { renderChannelConfigSection } from "./channels.config"; + +export function renderSlackCard(params: { + props: ChannelsProps; + slack?: SlackStatus | null; + accountCountLabel: unknown; +}) { + const { props, slack, accountCountLabel } = params; + + return html` +
+
Slack
+
Socket mode status and channel configuration.
+ ${accountCountLabel} + +
+
+ Configured + ${slack?.configured ? "Yes" : "No"} +
+
+ Running + ${slack?.running ? "Yes" : "No"} +
+
+ Last start + ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"} +
+
+ Last probe + ${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"} +
+
+ + ${slack?.lastError + ? html`
+ ${slack.lastError} +
` + : nothing} + + ${slack?.probe + ? html`
+ Probe ${slack.probe.ok ? "ok" : "failed"} · + ${slack.probe.status ?? ""} ${slack.probe.error ?? ""} +
` + : nothing} + + ${renderChannelConfigSection({ channelId: "slack", props })} + +
+ +
+
+ `; +} diff --git a/ui/src/ui/views/channels.telegram.ts b/ui/src/ui/views/channels.telegram.ts new file mode 100644 index 000000000..498d98f87 --- /dev/null +++ b/ui/src/ui/views/channels.telegram.ts @@ -0,0 +1,113 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { ChannelAccountSnapshot, TelegramStatus } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { renderChannelConfigSection } from "./channels.config"; + +export function renderTelegramCard(params: { + props: ChannelsProps; + telegram?: TelegramStatus; + telegramAccounts: ChannelAccountSnapshot[]; + accountCountLabel: unknown; +}) { + const { props, telegram, telegramAccounts, accountCountLabel } = params; + const hasMultipleAccounts = telegramAccounts.length > 1; + + const renderAccountCard = (account: ChannelAccountSnapshot) => { + const probe = account.probe as { bot?: { username?: string } } | undefined; + const botUsername = probe?.bot?.username; + const label = account.name || account.accountId; + return html` + + `; + }; + + return html` +
+
Telegram
+
Bot status and channel configuration.
+ ${accountCountLabel} + + ${hasMultipleAccounts + ? html` + + ` + : html` +
+
+ Configured + ${telegram?.configured ? "Yes" : "No"} +
+
+ Running + ${telegram?.running ? "Yes" : "No"} +
+
+ Mode + ${telegram?.mode ?? "n/a"} +
+
+ Last start + ${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"} +
+
+ Last probe + ${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"} +
+
+ `} + + ${telegram?.lastError + ? html`
+ ${telegram.lastError} +
` + : nothing} + + ${telegram?.probe + ? html`
+ Probe ${telegram.probe.ok ? "ok" : "failed"} · + ${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""} +
` + : nothing} + + ${renderChannelConfigSection({ channelId: "telegram", props })} + +
+ +
+
+ `; +} diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts new file mode 100644 index 000000000..50ab1029e --- /dev/null +++ b/ui/src/ui/views/channels.ts @@ -0,0 +1,234 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { + ChannelAccountSnapshot, + ChannelsStatusSnapshot, + DiscordStatus, + IMessageStatus, + SignalStatus, + SlackStatus, + TelegramStatus, + WhatsAppStatus, +} from "../types"; +import type { + ChannelKey, + ChannelsChannelData, + ChannelsProps, +} from "./channels.types"; +import { channelEnabled, renderChannelAccountCount } from "./channels.shared"; +import { renderChannelConfigSection } from "./channels.config"; +import { renderDiscordCard } from "./channels.discord"; +import { renderIMessageCard } from "./channels.imessage"; +import { renderSignalCard } from "./channels.signal"; +import { renderSlackCard } from "./channels.slack"; +import { renderTelegramCard } from "./channels.telegram"; +import { renderWhatsAppCard } from "./channels.whatsapp"; + +export function renderChannels(props: ChannelsProps) { + const channels = props.snapshot?.channels as Record | null; + const whatsapp = (channels?.whatsapp ?? undefined) as + | WhatsAppStatus + | undefined; + const telegram = (channels?.telegram ?? undefined) as + | TelegramStatus + | undefined; + const discord = (channels?.discord ?? null) as DiscordStatus | null; + const slack = (channels?.slack ?? null) as SlackStatus | null; + const signal = (channels?.signal ?? null) as SignalStatus | null; + const imessage = (channels?.imessage ?? null) as IMessageStatus | null; + const channelOrder = resolveChannelOrder(props.snapshot); + const orderedChannels = channelOrder + .map((key, index) => ({ + key, + enabled: channelEnabled(key, props), + order: index, + })) + .sort((a, b) => { + if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; + return a.order - b.order; + }); + + return html` +
+ ${orderedChannels.map((channel) => + renderChannel(channel.key, props, { + whatsapp, + telegram, + discord, + slack, + signal, + imessage, + channelAccounts: props.snapshot?.channelAccounts ?? null, + }), + )} +
+ +
+
+
+
Channel health
+
Channel status snapshots from the gateway.
+
+
${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}
+
+ ${props.lastError + ? html`
+ ${props.lastError} +
` + : nothing} +
+${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
+      
+
+ `; +} + +function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKey[] { + if (snapshot?.channelOrder?.length) { + return snapshot.channelOrder; + } + return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]; +} + +function renderChannel( + key: ChannelKey, + props: ChannelsProps, + data: ChannelsChannelData, +) { + const accountCountLabel = renderChannelAccountCount( + key, + data.channelAccounts, + ); + switch (key) { + case "whatsapp": + return renderWhatsAppCard({ + props, + whatsapp: data.whatsapp, + accountCountLabel, + }); + case "telegram": + return renderTelegramCard({ + props, + telegram: data.telegram, + telegramAccounts: data.channelAccounts?.telegram ?? [], + accountCountLabel, + }); + case "discord": + return renderDiscordCard({ + props, + discord: data.discord, + accountCountLabel, + }); + case "slack": + return renderSlackCard({ + props, + slack: data.slack, + accountCountLabel, + }); + case "signal": + return renderSignalCard({ + props, + signal: data.signal, + accountCountLabel, + }); + case "imessage": + return renderIMessageCard({ + props, + imessage: data.imessage, + accountCountLabel, + }); + default: + return renderGenericChannelCard(key, props, data.channelAccounts ?? {}); + } +} + +function renderGenericChannelCard( + key: ChannelKey, + props: ChannelsProps, + channelAccounts: Record, +) { + const label = props.snapshot?.channelLabels?.[key] ?? key; + const status = props.snapshot?.channels?.[key] as Record | undefined; + const configured = typeof status?.configured === "boolean" ? status.configured : undefined; + const running = typeof status?.running === "boolean" ? status.running : undefined; + const connected = typeof status?.connected === "boolean" ? status.connected : undefined; + const lastError = typeof status?.lastError === "string" ? status.lastError : undefined; + const accounts = channelAccounts[key] ?? []; + const accountCountLabel = renderChannelAccountCount(key, channelAccounts); + + return html` +
+
${label}
+
Channel status and configuration.
+ ${accountCountLabel} + + ${accounts.length > 0 + ? html` + + ` + : html` +
+
+ Configured + ${configured == null ? "n/a" : configured ? "Yes" : "No"} +
+
+ Running + ${running == null ? "n/a" : running ? "Yes" : "No"} +
+
+ Connected + ${connected == null ? "n/a" : connected ? "Yes" : "No"} +
+
+ `} + + ${lastError + ? html`
+ ${lastError} +
` + : nothing} + + ${renderChannelConfigSection({ channelId: key, props })} +
+ `; +} + +function renderGenericAccount(account: ChannelAccountSnapshot) { + return html` + + `; +} diff --git a/ui/src/ui/views/channels.types.ts b/ui/src/ui/views/channels.types.ts new file mode 100644 index 000000000..9984ee9c4 --- /dev/null +++ b/ui/src/ui/views/channels.types.ts @@ -0,0 +1,48 @@ +import type { + ChannelAccountSnapshot, + ChannelsStatusSnapshot, + ConfigUiHints, + DiscordStatus, + IMessageStatus, + SignalStatus, + SlackStatus, + TelegramStatus, + WhatsAppStatus, +} from "../types"; + +export type ChannelKey = string; + +export type ChannelsProps = { + connected: boolean; + loading: boolean; + snapshot: ChannelsStatusSnapshot | null; + lastError: string | null; + lastSuccessAt: number | null; + whatsappMessage: string | null; + whatsappQrDataUrl: string | null; + whatsappConnected: boolean | null; + whatsappBusy: boolean; + configSchema: unknown | null; + configSchemaLoading: boolean; + configForm: Record | null; + configUiHints: ConfigUiHints; + configSaving: boolean; + configFormDirty: boolean; + onRefresh: (probe: boolean) => void; + onWhatsAppStart: (force: boolean) => void; + onWhatsAppWait: () => void; + onWhatsAppLogout: () => void; + onConfigPatch: (path: Array, value: unknown) => void; + onConfigSave: () => void; + onConfigReload: () => void; +}; + +export type ChannelsChannelData = { + whatsapp?: WhatsAppStatus; + telegram?: TelegramStatus; + discord?: DiscordStatus | null; + slack?: SlackStatus | null; + signal?: SignalStatus | null; + imessage?: IMessageStatus | null; + channelAccounts?: Record | null; +}; diff --git a/ui/src/ui/views/connections.whatsapp.ts b/ui/src/ui/views/channels.whatsapp.ts similarity index 92% rename from ui/src/ui/views/connections.whatsapp.ts rename to ui/src/ui/views/channels.whatsapp.ts index ea3290a47..b40a533c6 100644 --- a/ui/src/ui/views/connections.whatsapp.ts +++ b/ui/src/ui/views/channels.whatsapp.ts @@ -2,11 +2,12 @@ import { html, nothing } from "lit"; import { formatAgo } from "../format"; import type { WhatsAppStatus } from "../types"; -import type { ConnectionsProps } from "./connections.types"; -import { formatDuration } from "./connections.shared"; +import type { ChannelsProps } from "./channels.types"; +import { renderChannelConfigSection } from "./channels.config"; +import { formatDuration } from "./channels.shared"; export function renderWhatsAppCard(params: { - props: ConnectionsProps; + props: ChannelsProps; whatsapp?: WhatsAppStatus; accountCountLabel: unknown; }) { @@ -110,6 +111,8 @@ export function renderWhatsAppCard(params: { Refresh + + ${renderChannelConfigSection({ channelId: "whatsapp", props })} `; } diff --git a/ui/src/ui/views/config-form.ts b/ui/src/ui/views/config-form.ts index d62222b20..6675bef26 100644 --- a/ui/src/ui/views/config-form.ts +++ b/ui/src/ui/views/config-form.ts @@ -3,5 +3,6 @@ export { analyzeConfigSchema, type ConfigSchemaAnalysis, } from "./config-form.analyze"; -export type { JsonSchema } from "./config-form.shared"; +export { renderNode } from "./config-form.node"; +export { schemaType, type JsonSchema } from "./config-form.shared"; diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 67bdf7166..0930e9920 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -1,20 +1,7 @@ import { html, nothing } from "lit"; -import { - MOONSHOT_KIMI_K2_CONTEXT_WINDOW, - MOONSHOT_KIMI_K2_COST, - MOONSHOT_KIMI_K2_DEFAULT_ID, - MOONSHOT_KIMI_K2_INPUT, - MOONSHOT_KIMI_K2_MAX_TOKENS, - MOONSHOT_KIMI_K2_MODELS, -} from "../data/moonshot-kimi-k2"; import type { ConfigUiHints } from "../types"; import { analyzeConfigSchema, renderConfigForm } from "./config-form"; -type ConfigPatch = { - path: Array; - value: unknown; -}; - export type ConfigProps = { raw: string; valid: boolean | null; @@ -38,287 +25,6 @@ export type ConfigProps = { onUpdate: () => void; }; -function cloneConfigObject(value: T): T { - if (typeof structuredClone === "function") return structuredClone(value); - return JSON.parse(JSON.stringify(value)) as T; -} - -function tryParseJsonObject(raw: string): Record | null { - try { - const parsed = JSON.parse(raw) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; - } - return null; - } catch { - return null; - } -} - -function setPathValue( - obj: Record | unknown[], - path: Array, - value: unknown, -) { - if (path.length === 0) return; - let current: Record | unknown[] = obj; - for (let i = 0; i < path.length - 1; i += 1) { - const key = path[i]; - const nextKey = path[i + 1]; - if (typeof key === "number") { - if (!Array.isArray(current)) return; - if (current[key] == null) { - current[key] = - typeof nextKey === "number" ? [] : ({} as Record); - } - current = current[key] as Record | unknown[]; - } else { - if (typeof current !== "object" || current == null) return; - const record = current as Record; - if (record[key] == null) { - record[key] = - typeof nextKey === "number" ? [] : ({} as Record); - } - current = record[key] as Record | unknown[]; - } - } - const lastKey = path[path.length - 1]; - if (typeof lastKey === "number") { - if (Array.isArray(current)) current[lastKey] = value; - return; - } - if (typeof current === "object" && current != null) { - (current as Record)[lastKey] = value; - } -} - -function getPathValue( - obj: unknown, - path: Array, -): unknown | undefined { - let current: unknown = obj; - for (const key of path) { - if (typeof key === "number") { - if (!Array.isArray(current)) return undefined; - current = current[key]; - } else { - if (!current || typeof current !== "object") return undefined; - current = (current as Record)[key]; - } - } - return current; -} - -function buildModelPresetPatches(base: Record): Array<{ - id: "minimax" | "zai" | "moonshot"; - title: string; - description: string; - patches: ConfigPatch[]; -}> { - const setPrimary = (modelRef: string) => ({ - path: ["agents", "defaults", "model", "primary"], - value: modelRef, - }); - const safeAlias = (modelRef: string, alias: string): ConfigPatch | null => { - const existingAlias = getPathValue(base, [ - "agents", - "defaults", - "models", - modelRef, - "alias", - ]); - if (typeof existingAlias === "string" && existingAlias.trim().length > 0) { - return null; - } - return { - path: ["agents", "defaults", "models", modelRef, "alias"], - value: alias, - }; - }; - - const minimaxModelsPath = ["models", "providers", "minimax", "models"] satisfies Array< - string | number - >; - const moonshotModelsPath = [ - "models", - "providers", - "moonshot", - "models", - ] satisfies Array; - - const hasNonEmptyString = (value: unknown) => - typeof value === "string" && value.trim().length > 0; - - const envMinimax = getPathValue(base, ["env", "MINIMAX_API_KEY"]); - const envZai = getPathValue(base, ["env", "ZAI_API_KEY"]); - const envMoonshot = getPathValue(base, ["env", "MOONSHOT_API_KEY"]); - - const minimaxHasModels = Array.isArray(getPathValue(base, minimaxModelsPath)); - const moonshotHasModels = Array.isArray(getPathValue(base, moonshotModelsPath)); - - const minimaxProviderBaseUrl = getPathValue(base, [ - "models", - "providers", - "minimax", - "baseUrl", - ]); - const minimaxProviderApiKey = getPathValue(base, [ - "models", - "providers", - "minimax", - "apiKey", - ]); - const minimaxProviderApi = getPathValue(base, [ - "models", - "providers", - "minimax", - "api", - ]); - const moonshotProviderBaseUrl = getPathValue(base, [ - "models", - "providers", - "moonshot", - "baseUrl", - ]); - const moonshotProviderApiKey = getPathValue(base, [ - "models", - "providers", - "moonshot", - "apiKey", - ]); - const moonshotProviderApi = getPathValue(base, [ - "models", - "providers", - "moonshot", - "api", - ]); - const modelsMode = getPathValue(base, ["models", "mode"]); - - const minimax: ConfigPatch[] = []; - if (!hasNonEmptyString(envMinimax)) { - minimax.push({ path: ["env", "MINIMAX_API_KEY"], value: "sk-..." }); - } - if (modelsMode == null) { - minimax.push({ path: ["models", "mode"], value: "merge" }); - } - // Intentional: enforce the preferred MiniMax endpoint/mode. - if (minimaxProviderBaseUrl !== "https://api.minimax.io/anthropic") { - minimax.push({ - path: ["models", "providers", "minimax", "baseUrl"], - value: "https://api.minimax.io/anthropic", - }); - } - if (!hasNonEmptyString(minimaxProviderApiKey)) { - minimax.push({ - path: ["models", "providers", "minimax", "apiKey"], - value: "${MINIMAX_API_KEY}", - }); - } - if (minimaxProviderApi !== "anthropic-messages") { - minimax.push({ - path: ["models", "providers", "minimax", "api"], - value: "anthropic-messages", - }); - } - if (!minimaxHasModels) { - minimax.push({ - path: minimaxModelsPath as Array, - value: [ - { - id: "MiniMax-M2.1", - name: "MiniMax M2.1", - reasoning: false, - input: ["text"], - cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 }, - contextWindow: 200000, - maxTokens: 8192, - }, - ], - }); - } - minimax.push(setPrimary("minimax/MiniMax-M2.1")); - const minimaxAlias = safeAlias("minimax/MiniMax-M2.1", "Minimax"); - if (minimaxAlias) minimax.push(minimaxAlias); - - const zai: ConfigPatch[] = []; - if (!hasNonEmptyString(envZai)) { - zai.push({ path: ["env", "ZAI_API_KEY"], value: "sk-..." }); - } - zai.push(setPrimary("zai/glm-4.7")); - const zaiAlias = safeAlias("zai/glm-4.7", "GLM 4.7"); - if (zaiAlias) zai.push(zaiAlias); - - const moonshot: ConfigPatch[] = []; - if (!hasNonEmptyString(envMoonshot)) { - moonshot.push({ path: ["env", "MOONSHOT_API_KEY"], value: "sk-..." }); - } - if (modelsMode == null) { - moonshot.push({ path: ["models", "mode"], value: "merge" }); - } - if (!hasNonEmptyString(moonshotProviderBaseUrl)) { - moonshot.push({ - path: ["models", "providers", "moonshot", "baseUrl"], - value: "https://api.moonshot.ai/v1", - }); - } - if (!hasNonEmptyString(moonshotProviderApiKey)) { - moonshot.push({ - path: ["models", "providers", "moonshot", "apiKey"], - value: "${MOONSHOT_API_KEY}", - }); - } - if (!hasNonEmptyString(moonshotProviderApi)) { - moonshot.push({ - path: ["models", "providers", "moonshot", "api"], - value: "openai-completions", - }); - } - const moonshotModelDefinitions = MOONSHOT_KIMI_K2_MODELS.map((model) => ({ - id: model.id, - name: model.name, - reasoning: model.reasoning, - input: [...MOONSHOT_KIMI_K2_INPUT], - cost: { ...MOONSHOT_KIMI_K2_COST }, - contextWindow: MOONSHOT_KIMI_K2_CONTEXT_WINDOW, - maxTokens: MOONSHOT_KIMI_K2_MAX_TOKENS, - })); - - if (!moonshotHasModels) { - moonshot.push({ - path: moonshotModelsPath as Array, - value: moonshotModelDefinitions, - }); - } - moonshot.push(setPrimary(`moonshot/${MOONSHOT_KIMI_K2_DEFAULT_ID}`)); - for (const model of MOONSHOT_KIMI_K2_MODELS) { - const moonshotAlias = safeAlias(`moonshot/${model.id}`, model.alias); - if (moonshotAlias) moonshot.push(moonshotAlias); - } - - return [ - { - id: "minimax", - title: "MiniMax M2.1 (Anthropic)", - description: - "Adds provider config for MiniMax’s /anthropic endpoint and sets it as the default model.", - patches: minimax, - }, - { - id: "zai", - title: "GLM 4.7 (Z.AI)", - description: "Adds ZAI_API_KEY placeholder + sets default model to zai/glm-4.7.", - patches: zai, - }, - { - id: "moonshot", - title: "Kimi (Moonshot)", - description: - "Adds Moonshot provider config + sets default model to kimi-k2-0905-preview (includes Kimi K2 turbo/thinking variants).", - patches: moonshot, - }, - ]; -} - export function renderConfig(props: ConfigProps) { const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid"; @@ -339,25 +45,6 @@ export function renderConfig(props: ConfigProps) { (props.formMode === "raw" ? true : canSaveForm); const canUpdate = props.connected && !props.applying && !props.updating; - const applyPreset = (patches: ConfigPatch[]) => { - const base = - props.formValue ?? - tryParseJsonObject(props.raw) ?? - ({} as Record); - const next = cloneConfigObject(base); - for (const patch of patches) { - setPathValue(next, patch.path, patch.value); - } - props.onRawChange(`${JSON.stringify(next, null, 2).trimEnd()}\n`); - for (const patch of patches) props.onFormPatch(patch.path, patch.value); - }; - - const presetBase = - props.formValue ?? - tryParseJsonObject(props.raw) ?? - ({} as Record); - const modelPresets = buildModelPresetPatches(presetBase); - return html`
@@ -414,31 +101,6 @@ export function renderConfig(props: ConfigProps) { comes back.
-
-
Model presets
-
- One-click inserts for MiniMax, GLM 4.7 (Z.AI), and Kimi (Moonshot). Keeps - existing API keys and per-model params when present. -
-
- ${modelPresets.map( - (preset) => html` - - `, - )} -
-
- Tip: use /model to switch models without editing - config. -
-
${props.formMode === "form" ? html`
diff --git a/ui/src/ui/views/connections.action-options.ts b/ui/src/ui/views/connections.action-options.ts deleted file mode 100644 index dd68e12ea..000000000 --- a/ui/src/ui/views/connections.action-options.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { DiscordActionForm, SlackActionForm } from "../ui-types"; - -export const discordActionOptions = [ - { key: "reactions", label: "Reactions" }, - { key: "stickers", label: "Stickers" }, - { key: "polls", label: "Polls" }, - { key: "permissions", label: "Permissions" }, - { key: "messages", label: "Messages" }, - { key: "threads", label: "Threads" }, - { key: "pins", label: "Pins" }, - { key: "search", label: "Search" }, - { key: "memberInfo", label: "Member info" }, - { key: "roleInfo", label: "Role info" }, - { key: "channelInfo", label: "Channel info" }, - { key: "voiceStatus", label: "Voice status" }, - { key: "events", label: "Events" }, - { key: "roles", label: "Role changes" }, - { key: "moderation", label: "Moderation" }, -] satisfies Array<{ key: keyof DiscordActionForm; label: string }>; - -export const slackActionOptions = [ - { key: "reactions", label: "Reactions" }, - { key: "messages", label: "Messages" }, - { key: "pins", label: "Pins" }, - { key: "memberInfo", label: "Member info" }, - { key: "emojiList", label: "Emoji list" }, -] satisfies Array<{ key: keyof SlackActionForm; label: string }>; - diff --git a/ui/src/ui/views/connections.discord.actions.ts b/ui/src/ui/views/connections.discord.actions.ts deleted file mode 100644 index 283f3ff19..000000000 --- a/ui/src/ui/views/connections.discord.actions.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { html } from "lit"; - -import type { ConnectionsProps } from "./connections.types"; -import { discordActionOptions } from "./connections.action-options"; - -export function renderDiscordActionsSection(props: ConnectionsProps) { - return html` -
Tool actions
-
- ${discordActionOptions.map( - (action) => html``, - )} -
- `; -} - diff --git a/ui/src/ui/views/connections.discord.guilds.ts b/ui/src/ui/views/connections.discord.guilds.ts deleted file mode 100644 index 2d4fd32d1..000000000 --- a/ui/src/ui/views/connections.discord.guilds.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { html, nothing } from "lit"; - -import type { ConnectionsProps } from "./connections.types"; - -export function renderDiscordGuildsEditor(props: ConnectionsProps) { - return html` -
- Guilds -
- Add each guild (id or slug) and optional channel rules. Empty channel - entries still allow that channel. -
-
- ${props.discordForm.guilds.map( - (guild, guildIndex) => html` -
-
-
- - - - - -
- ${guild.channels.length - ? html` -
- ${guild.channels.map( - (channel, channelIndex) => html` - - - - - `, - )} -
- ` - : nothing} -
-
- Channels - - -
-
- `, - )} -
- -
- `; -} - diff --git a/ui/src/ui/views/connections.discord.ts b/ui/src/ui/views/connections.discord.ts deleted file mode 100644 index 54a84e316..000000000 --- a/ui/src/ui/views/connections.discord.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { html, nothing } from "lit"; - -import { formatAgo } from "../format"; -import type { DiscordStatus } from "../types"; -import type { ConnectionsProps } from "./connections.types"; -import { renderDiscordActionsSection } from "./connections.discord.actions"; -import { renderDiscordGuildsEditor } from "./connections.discord.guilds"; - -export function renderDiscordCard(params: { - props: ConnectionsProps; - discord: DiscordStatus | null; - accountCountLabel: unknown; -}) { - const { props, discord, accountCountLabel } = params; - const botName = discord?.probe?.bot?.username; - - return html` -
-
Discord
-
Bot connection and probe status.
- ${accountCountLabel} - -
-
- Configured - ${discord?.configured ? "Yes" : "No"} -
-
- Running - ${discord?.running ? "Yes" : "No"} -
-
- Bot - ${botName ? `@${botName}` : "n/a"} -
-
- Last start - ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"} -
-
- Last probe - ${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"} -
-
- - ${discord?.lastError - ? html`
- ${discord.lastError} -
` - : nothing} - - ${discord?.probe - ? html`
- Probe ${discord.probe.ok ? "ok" : "failed"} · - ${discord.probe.status ?? ""} ${discord.probe.error ?? ""} -
` - : nothing} - -
- - - - - - - - - - - ${renderDiscordGuildsEditor(props)} - - - - -
- - ${renderDiscordActionsSection(props)} - - ${props.discordTokenLocked - ? html`
- DISCORD_BOT_TOKEN is set in the environment. Config edits will not - override it. -
` - : nothing} - - ${props.discordStatus - ? html`
- ${props.discordStatus} -
` - : nothing} - -
- - -
-
- `; -} diff --git a/ui/src/ui/views/connections.imessage.ts b/ui/src/ui/views/connections.imessage.ts deleted file mode 100644 index fcdbf0b12..000000000 --- a/ui/src/ui/views/connections.imessage.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { html, nothing } from "lit"; - -import { formatAgo } from "../format"; -import type { IMessageStatus } from "../types"; -import type { ConnectionsProps } from "./connections.types"; - -export function renderIMessageCard(params: { - props: ConnectionsProps; - imessage: IMessageStatus | null; - accountCountLabel: unknown; -}) { - const { props, imessage, accountCountLabel } = params; - - return html` -
-
iMessage
-
imsg CLI and database availability.
- ${accountCountLabel} - -
-
- Configured - ${imessage?.configured ? "Yes" : "No"} -
-
- Running - ${imessage?.running ? "Yes" : "No"} -
-
- CLI - ${imessage?.cliPath ?? "n/a"} -
-
- DB - ${imessage?.dbPath ?? "n/a"} -
-
- Last start - - ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"} - -
-
- Last probe - - ${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"} - -
-
- - ${imessage?.lastError - ? html`
- ${imessage.lastError} -
` - : nothing} - - ${imessage?.probe && !imessage.probe.ok - ? html`
- Probe failed · ${imessage.probe.error ?? "unknown error"} -
` - : nothing} - -
- - - - - - - - -
- - ${props.imessageStatus - ? html`
- ${props.imessageStatus} -
` - : nothing} - -
- - -
-
- `; -} - diff --git a/ui/src/ui/views/connections.shared.ts b/ui/src/ui/views/connections.shared.ts deleted file mode 100644 index bf50337ce..000000000 --- a/ui/src/ui/views/connections.shared.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { html, nothing } from "lit"; - -import type { - DiscordStatus, - IMessageStatus, - SignalStatus, - SlackStatus, - TelegramStatus, - WhatsAppStatus, -} from "../types"; -import type { ChannelAccountSnapshot } from "../types"; -import type { ChannelKey, ConnectionsProps } from "./connections.types"; - -export function formatDuration(ms?: number | null) { - if (!ms && ms !== 0) return "n/a"; - const sec = Math.round(ms / 1000); - if (sec < 60) return `${sec}s`; - const min = Math.round(sec / 60); - if (min < 60) return `${min}m`; - const hr = Math.round(min / 60); - return `${hr}h`; -} - -export function channelEnabled(key: ChannelKey, props: ConnectionsProps) { - const snapshot = props.snapshot; - const channels = snapshot?.channels as Record | null; - if (!snapshot || !channels) return false; - const whatsapp = channels.whatsapp as WhatsAppStatus | undefined; - const telegram = channels.telegram as TelegramStatus | undefined; - const discord = (channels.discord ?? null) as DiscordStatus | null; - const slack = (channels.slack ?? null) as SlackStatus | null; - const signal = (channels.signal ?? null) as SignalStatus | null; - const imessage = (channels.imessage ?? null) as IMessageStatus | null; - switch (key) { - case "whatsapp": - return ( - Boolean(whatsapp?.configured) || - Boolean(whatsapp?.linked) || - Boolean(whatsapp?.running) - ); - case "telegram": - return Boolean(telegram?.configured) || Boolean(telegram?.running); - case "discord": - return Boolean(discord?.configured || discord?.running); - case "slack": - return Boolean(slack?.configured || slack?.running); - case "signal": - return Boolean(signal?.configured || signal?.running); - case "imessage": - return Boolean(imessage?.configured || imessage?.running); - default: - return false; - } -} - -export function getChannelAccountCount( - key: ChannelKey, - channelAccounts?: Record | null, -): number { - return channelAccounts?.[key]?.length ?? 0; -} - -export function renderChannelAccountCount( - key: ChannelKey, - channelAccounts?: Record | null, -) { - const count = getChannelAccountCount(key, channelAccounts); - if (count < 2) return nothing; - return html``; -} - diff --git a/ui/src/ui/views/connections.signal.ts b/ui/src/ui/views/connections.signal.ts deleted file mode 100644 index ca04d6bc9..000000000 --- a/ui/src/ui/views/connections.signal.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { html, nothing } from "lit"; - -import { formatAgo } from "../format"; -import type { SignalStatus } from "../types"; -import type { ConnectionsProps } from "./connections.types"; - -export function renderSignalCard(params: { - props: ConnectionsProps; - signal: SignalStatus | null; - accountCountLabel: unknown; -}) { - const { props, signal, accountCountLabel } = params; - - return html` -
-
Signal
-
REST daemon status and probe details.
- ${accountCountLabel} - -
-
- Configured - ${signal?.configured ? "Yes" : "No"} -
-
- Running - ${signal?.running ? "Yes" : "No"} -
-
- Base URL - ${signal?.baseUrl ?? "n/a"} -
-
- Last start - ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"} -
-
- Last probe - ${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"} -
-
- - ${signal?.lastError - ? html`
- ${signal.lastError} -
` - : nothing} - - ${signal?.probe - ? html`
- Probe ${signal.probe.ok ? "ok" : "failed"} · - ${signal.probe.status ?? ""} ${signal.probe.error ?? ""} -
` - : nothing} - -
- - - - - - - - - - - - - -
- - ${props.signalStatus - ? html`
- ${props.signalStatus} -
` - : nothing} - -
- - -
-
- `; -} - diff --git a/ui/src/ui/views/connections.slack.ts b/ui/src/ui/views/connections.slack.ts deleted file mode 100644 index 4116c649d..000000000 --- a/ui/src/ui/views/connections.slack.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { html, nothing } from "lit"; - -import { formatAgo } from "../format"; -import type { SlackStatus } from "../types"; -import type { ConnectionsProps } from "./connections.types"; -import { slackActionOptions } from "./connections.action-options"; - -export function renderSlackCard(params: { - props: ConnectionsProps; - slack: SlackStatus | null; - accountCountLabel: unknown; -}) { - const { props, slack, accountCountLabel } = params; - const botName = slack?.probe?.bot?.name; - const teamName = slack?.probe?.team?.name; - - return html` -
-
Slack
-
Socket mode status and bot details.
- ${accountCountLabel} - -
-
- Configured - ${slack?.configured ? "Yes" : "No"} -
-
- Running - ${slack?.running ? "Yes" : "No"} -
-
- Bot - ${botName ? botName : "n/a"} -
-
- Team - ${teamName ? teamName : "n/a"} -
-
- Last start - ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"} -
-
- Last probe - ${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"} -
-
- - ${slack?.lastError - ? html`
- ${slack.lastError} -
` - : nothing} - - ${slack?.probe - ? html`
- Probe ${slack.probe.ok ? "ok" : "failed"} · ${slack.probe.status ?? ""} - ${slack.probe.error ?? ""} -
` - : nothing} - -
- - - - - - - - - - - -
- -
Slash command
-
- - - - -
- -
Channels
-
Add channel ids or #names and optionally require mentions.
-
- ${props.slackForm.channels.map( - (channel, channelIndex) => html` -
-
-
- - - - -
-
-
- `, - )} -
- - -
Tool actions
-
- ${slackActionOptions.map( - (action) => html``, - )} -
- - ${props.slackTokenLocked || props.slackAppTokenLocked - ? html`
- ${props.slackTokenLocked ? "SLACK_BOT_TOKEN " : ""} - ${props.slackAppTokenLocked ? "SLACK_APP_TOKEN " : ""} is set in the - environment. Config edits will not override it. -
` - : nothing} - - ${props.slackStatus - ? html`
- ${props.slackStatus} -
` - : nothing} - -
- - -
-
- `; -} - diff --git a/ui/src/ui/views/connections.telegram.ts b/ui/src/ui/views/connections.telegram.ts deleted file mode 100644 index 8c11cdf5a..000000000 --- a/ui/src/ui/views/connections.telegram.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { html, nothing } from "lit"; - -import { formatAgo } from "../format"; -import type { ChannelAccountSnapshot, TelegramStatus } from "../types"; -import type { ConnectionsProps } from "./connections.types"; - -export function renderTelegramCard(params: { - props: ConnectionsProps; - telegram?: TelegramStatus; - telegramAccounts: ChannelAccountSnapshot[]; - accountCountLabel: unknown; -}) { - const { props, telegram, telegramAccounts, accountCountLabel } = params; - const hasMultipleAccounts = telegramAccounts.length > 1; - - const renderAccountCard = (account: ChannelAccountSnapshot) => { - const probe = account.probe as { bot?: { username?: string } } | undefined; - const botUsername = probe?.bot?.username; - const label = account.name || account.accountId; - return html` - - `; - }; - - return html` -
-
Telegram
-
Bot token and delivery options.
- ${accountCountLabel} - - ${hasMultipleAccounts - ? html` - - ` - : html` -
-
- Configured - ${telegram?.configured ? "Yes" : "No"} -
-
- Running - ${telegram?.running ? "Yes" : "No"} -
-
- Mode - ${telegram?.mode ?? "n/a"} -
-
- Last start - ${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"} -
-
- Last probe - ${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"} -
-
- `} - - ${telegram?.lastError - ? html`
- ${telegram.lastError} -
` - : nothing} - - ${telegram?.probe - ? html`
- Probe ${telegram.probe.ok ? "ok" : "failed"} · - ${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""} -
` - : nothing} - -
- - - - - - - - -
- -
- Allow from supports numeric user IDs (recommended) or @usernames. DM the bot - to get your ID, or run /whoami. -
- - ${props.telegramTokenLocked - ? html`
- TELEGRAM_BOT_TOKEN is set in the environment. Config edits will not override it. -
` - : nothing} - - ${props.telegramForm.groupsWildcardEnabled - ? html`
- This writes telegram.groups["*"] and allows all groups. Remove it - if you only want specific groups. -
- -
-
` - : nothing} - - ${props.telegramStatus - ? html`
- ${props.telegramStatus} -
` - : nothing} - -
- - -
-
- `; -} - diff --git a/ui/src/ui/views/connections.ts b/ui/src/ui/views/connections.ts deleted file mode 100644 index 2c7aef25e..000000000 --- a/ui/src/ui/views/connections.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { html, nothing } from "lit"; - -import { formatAgo } from "../format"; -import type { - DiscordStatus, - IMessageStatus, - SignalStatus, - SlackStatus, - TelegramStatus, - WhatsAppStatus, -} from "../types"; -import type { - ChannelKey, - ConnectionsChannelData, - ConnectionsProps, -} from "./connections.types"; -import { channelEnabled, renderChannelAccountCount } from "./connections.shared"; -import { renderDiscordCard } from "./connections.discord"; -import { renderIMessageCard } from "./connections.imessage"; -import { renderSignalCard } from "./connections.signal"; -import { renderSlackCard } from "./connections.slack"; -import { renderTelegramCard } from "./connections.telegram"; -import { renderWhatsAppCard } from "./connections.whatsapp"; - -export function renderConnections(props: ConnectionsProps) { - const channels = props.snapshot?.channels as Record | null; - const whatsapp = (channels?.whatsapp ?? undefined) as - | WhatsAppStatus - | undefined; - const telegram = (channels?.telegram ?? undefined) as - | TelegramStatus - | undefined; - const discord = (channels?.discord ?? null) as DiscordStatus | null; - const slack = (channels?.slack ?? null) as SlackStatus | null; - const signal = (channels?.signal ?? null) as SignalStatus | null; - const imessage = (channels?.imessage ?? null) as IMessageStatus | null; - const channelOrder: ChannelKey[] = [ - "whatsapp", - "telegram", - "discord", - "slack", - "signal", - "imessage", - ]; - const orderedChannels = channelOrder - .map((key, index) => ({ - key, - enabled: channelEnabled(key, props), - order: index, - })) - .sort((a, b) => { - if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; - return a.order - b.order; - }); - - return html` -
- ${orderedChannels.map((channel) => - renderChannel(channel.key, props, { - whatsapp, - telegram, - discord, - slack, - signal, - imessage, - channelAccounts: props.snapshot?.channelAccounts ?? null, - }), - )} -
- -
-
-
-
Connection health
-
Channel status snapshots from the gateway.
-
-
${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}
-
- ${props.lastError - ? html`
- ${props.lastError} -
` - : nothing} -
-${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
-      
-
- `; -} - -function renderChannel( - key: ChannelKey, - props: ConnectionsProps, - data: ConnectionsChannelData, -) { - const accountCountLabel = renderChannelAccountCount( - key, - data.channelAccounts, - ); - switch (key) { - case "whatsapp": - return renderWhatsAppCard({ - props, - whatsapp: data.whatsapp, - accountCountLabel, - }); - case "telegram": - return renderTelegramCard({ - props, - telegram: data.telegram, - telegramAccounts: data.channelAccounts?.telegram ?? [], - accountCountLabel, - }); - case "discord": - return renderDiscordCard({ - props, - discord: data.discord, - accountCountLabel, - }); - case "slack": - return renderSlackCard({ - props, - slack: data.slack, - accountCountLabel, - }); - case "signal": - return renderSignalCard({ - props, - signal: data.signal, - accountCountLabel, - }); - case "imessage": - return renderIMessageCard({ - props, - imessage: data.imessage, - accountCountLabel, - }); - default: - return nothing; - } -} diff --git a/ui/src/ui/views/connections.types.ts b/ui/src/ui/views/connections.types.ts deleted file mode 100644 index b7b750b15..000000000 --- a/ui/src/ui/views/connections.types.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { - ChannelAccountSnapshot, - ChannelsStatusSnapshot, - DiscordStatus, - IMessageStatus, - SignalStatus, - SlackStatus, - TelegramStatus, - WhatsAppStatus, -} from "../types"; -import type { - DiscordForm, - IMessageForm, - SignalForm, - SlackForm, - TelegramForm, -} from "../ui-types"; - -export type ChannelKey = - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage"; - -export type ConnectionsProps = { - connected: boolean; - loading: boolean; - snapshot: ChannelsStatusSnapshot | null; - lastError: string | null; - lastSuccessAt: number | null; - whatsappMessage: string | null; - whatsappQrDataUrl: string | null; - whatsappConnected: boolean | null; - whatsappBusy: boolean; - telegramForm: TelegramForm; - telegramTokenLocked: boolean; - telegramSaving: boolean; - telegramStatus: string | null; - discordForm: DiscordForm; - discordTokenLocked: boolean; - discordSaving: boolean; - discordStatus: string | null; - slackForm: SlackForm; - slackTokenLocked: boolean; - slackAppTokenLocked: boolean; - slackSaving: boolean; - slackStatus: string | null; - signalForm: SignalForm; - signalSaving: boolean; - signalStatus: string | null; - imessageForm: IMessageForm; - imessageSaving: boolean; - imessageStatus: string | null; - onRefresh: (probe: boolean) => void; - onWhatsAppStart: (force: boolean) => void; - onWhatsAppWait: () => void; - onWhatsAppLogout: () => void; - onTelegramChange: (patch: Partial) => void; - onTelegramSave: () => void; - onDiscordChange: (patch: Partial) => void; - onDiscordSave: () => void; - onSlackChange: (patch: Partial) => void; - onSlackSave: () => void; - onSignalChange: (patch: Partial) => void; - onSignalSave: () => void; - onIMessageChange: (patch: Partial) => void; - onIMessageSave: () => void; -}; - -export type ConnectionsChannelData = { - whatsapp?: WhatsAppStatus; - telegram?: TelegramStatus; - discord?: DiscordStatus | null; - slack?: SlackStatus | null; - signal?: SignalStatus | null; - imessage?: IMessageStatus | null; - channelAccounts?: Record | null; -}; - diff --git a/ui/src/ui/views/nodes.ts b/ui/src/ui/views/nodes.ts index 1b621a752..44880c01d 100644 --- a/ui/src/ui/views/nodes.ts +++ b/ui/src/ui/views/nodes.ts @@ -12,7 +12,7 @@ export function renderNodes(props: NodesProps) {
Nodes
-
Paired devices and live connections.
+
Paired devices and live links.
` : html`
- Use Connections to link WhatsApp, Telegram, Discord, Signal, or iMessage. + Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
`}