diff --git a/apps/macos/Sources/Clawdbot/ConnectionsSettings+Helpers.swift b/apps/macos/Sources/Clawdbot/ConnectionsSettings+Helpers.swift new file mode 100644 index 000000000..abfdb61de --- /dev/null +++ b/apps/macos/Sources/Clawdbot/ConnectionsSettings+Helpers.swift @@ -0,0 +1,17 @@ +import AppKit + +extension ConnectionsSettings { + func date(fromMs ms: Double?) -> Date? { + guard let ms else { return nil } + return Date(timeIntervalSince1970: ms / 1000) + } + + func qrImage(from dataUrl: String) -> NSImage? { + guard let comma = dataUrl.firstIndex(of: ",") else { return nil } + let header = dataUrl[.. some View) -> some View { + GroupBox(title) { + VStack(alignment: .leading, spacing: 10) { + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + @ViewBuilder + func providerHeaderActions(_ provider: ConnectionProvider) -> some View { + HStack(spacing: 8) { + if provider == .whatsapp { + Button("Logout") { + Task { await self.store.logoutWhatsApp() } + } + .buttonStyle(.bordered) + .disabled(self.store.whatsappBusy) + } + + if provider == .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+ProviderState.swift b/apps/macos/Sources/Clawdbot/ConnectionsSettings+ProviderState.swift new file mode 100644 index 000000000..ab0b92577 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/ConnectionsSettings+ProviderState.swift @@ -0,0 +1,379 @@ +import SwiftUI + +extension ConnectionsSettings { + var whatsAppTint: Color { + guard let status = self.store.snapshot?.whatsapp else { return .secondary } + if !status.configured { return .secondary } + if !status.linked { return .red } + if status.lastError != nil { return .orange } + if status.connected { return .green } + if status.running { return .orange } + return .orange + } + + var telegramTint: Color { + guard let status = self.store.snapshot?.telegram else { return .secondary } + if !status.configured { return .secondary } + if status.lastError != nil { return .orange } + if status.probe?.ok == false { return .orange } + if status.running { return .green } + return .orange + } + + var discordTint: Color { + guard let status = self.store.snapshot?.discord else { return .secondary } + if !status.configured { return .secondary } + if status.lastError != nil { return .orange } + if status.probe?.ok == false { return .orange } + if status.running { return .green } + return .orange + } + + var signalTint: Color { + guard let status = self.store.snapshot?.signal else { return .secondary } + if !status.configured { return .secondary } + if status.lastError != nil { return .orange } + if status.probe?.ok == false { return .orange } + if status.running { return .green } + return .orange + } + + var imessageTint: Color { + guard let status = self.store.snapshot?.imessage else { return .secondary } + if !status.configured { return .secondary } + if status.lastError != nil { return .orange } + if status.probe?.ok == false { return .orange } + if status.running { return .green } + return .orange + } + + var whatsAppSummary: String { + guard let status = self.store.snapshot?.whatsapp else { return "Checking…" } + if !status.linked { return "Not linked" } + if status.connected { return "Connected" } + if status.running { return "Running" } + return "Linked" + } + + var telegramSummary: String { + guard let status = self.store.snapshot?.telegram else { return "Checking…" } + if !status.configured { return "Not configured" } + if status.running { return "Running" } + return "Configured" + } + + var discordSummary: String { + guard let status = self.store.snapshot?.discord else { return "Checking…" } + if !status.configured { return "Not configured" } + if status.running { return "Running" } + return "Configured" + } + + var signalSummary: String { + guard let status = self.store.snapshot?.signal else { return "Checking…" } + if !status.configured { return "Not configured" } + if status.running { return "Running" } + return "Configured" + } + + var imessageSummary: String { + guard let status = self.store.snapshot?.imessage else { return "Checking…" } + if !status.configured { return "Not configured" } + if status.running { return "Running" } + return "Configured" + } + + var whatsAppDetails: String? { + guard let status = self.store.snapshot?.whatsapp else { return nil } + var lines: [String] = [] + if let e164 = status.`self`?.e164 ?? status.`self`?.jid { + lines.append("Linked as \(e164)") + } + if let age = status.authAgeMs { + lines.append("Auth age \(msToAge(age))") + } + if let last = self.date(fromMs: status.lastConnectedAt) { + lines.append("Last connect \(relativeAge(from: last))") + } + if let disconnect = status.lastDisconnect { + let when = self.date(fromMs: disconnect.at).map { relativeAge(from: $0) } ?? "unknown" + let code = disconnect.status.map { "status \($0)" } ?? "status unknown" + let err = disconnect.error ?? "disconnect" + lines.append("Last disconnect \(code) · \(err) · \(when)") + } + if status.reconnectAttempts > 0 { + lines.append("Reconnect attempts \(status.reconnectAttempts)") + } + if let msgAt = self.date(fromMs: status.lastMessageAt) { + lines.append("Last message \(relativeAge(from: msgAt))") + } + if let err = status.lastError, !err.isEmpty { + lines.append("Error: \(err)") + } + return lines.isEmpty ? nil : lines.joined(separator: " · ") + } + + var telegramDetails: String? { + guard let status = self.store.snapshot?.telegram else { return nil } + var lines: [String] = [] + if let source = status.tokenSource { + lines.append("Token source: \(source)") + } + if let mode = status.mode { + lines.append("Mode: \(mode)") + } + if let probe = status.probe { + if probe.ok { + if let name = probe.bot?.username { + lines.append("Bot: @\(name)") + } + if let url = probe.webhook?.url, !url.isEmpty { + lines.append("Webhook: \(url)") + } + } else { + let code = probe.status.map { String($0) } ?? "unknown" + lines.append("Probe failed (\(code))") + } + } + if let last = self.date(fromMs: status.lastProbeAt) { + lines.append("Last probe \(relativeAge(from: last))") + } + if let err = status.lastError, !err.isEmpty { + lines.append("Error: \(err)") + } + return lines.isEmpty ? nil : lines.joined(separator: " · ") + } + + var discordDetails: String? { + guard let status = self.store.snapshot?.discord else { return nil } + var lines: [String] = [] + if let source = status.tokenSource { + lines.append("Token source: \(source)") + } + if let probe = status.probe { + if probe.ok { + if let name = probe.bot?.username { + lines.append("Bot: @\(name)") + } + if let elapsed = probe.elapsedMs { + lines.append("Probe \(Int(elapsed))ms") + } + } else { + let code = probe.status.map { String($0) } ?? "unknown" + lines.append("Probe failed (\(code))") + } + } + if let last = self.date(fromMs: status.lastProbeAt) { + lines.append("Last probe \(relativeAge(from: last))") + } + if let err = status.lastError, !err.isEmpty { + lines.append("Error: \(err)") + } + return lines.isEmpty ? nil : lines.joined(separator: " · ") + } + + var signalDetails: String? { + guard let status = self.store.snapshot?.signal else { return nil } + var lines: [String] = [] + lines.append("Base URL: \(status.baseUrl)") + if let probe = status.probe { + if probe.ok { + if let version = probe.version, !version.isEmpty { + lines.append("Version \(version)") + } + if let elapsed = probe.elapsedMs { + lines.append("Probe \(Int(elapsed))ms") + } + } else { + let code = probe.status.map { String($0) } ?? "unknown" + lines.append("Probe failed (\(code))") + } + } + if let last = self.date(fromMs: status.lastProbeAt) { + lines.append("Last probe \(relativeAge(from: last))") + } + if let err = status.lastError, !err.isEmpty { + lines.append("Error: \(err)") + } + return lines.isEmpty ? nil : lines.joined(separator: " · ") + } + + var imessageDetails: String? { + guard let status = self.store.snapshot?.imessage else { return nil } + var lines: [String] = [] + if let cliPath = status.cliPath, !cliPath.isEmpty { + lines.append("CLI: \(cliPath)") + } + if let dbPath = status.dbPath, !dbPath.isEmpty { + lines.append("DB: \(dbPath)") + } + if let probe = status.probe, !probe.ok { + let err = probe.error ?? "probe failed" + lines.append("Probe error: \(err)") + } + if let last = self.date(fromMs: status.lastProbeAt) { + lines.append("Last probe \(relativeAge(from: last))") + } + if let err = status.lastError, !err.isEmpty { + lines.append("Error: \(err)") + } + return lines.isEmpty ? nil : lines.joined(separator: " · ") + } + + var isTelegramTokenLocked: Bool { + self.store.snapshot?.telegram.tokenSource == "env" + } + + var isDiscordTokenLocked: Bool { + self.store.snapshot?.discord?.tokenSource == "env" + } + + var orderedProviders: [ConnectionProvider] { + ConnectionProvider.allCases.sorted { lhs, rhs in + let lhsEnabled = self.providerEnabled(lhs) + let rhsEnabled = self.providerEnabled(rhs) + if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled } + return lhs.sortOrder < rhs.sortOrder + } + } + + var enabledProviders: [ConnectionProvider] { + self.orderedProviders.filter { self.providerEnabled($0) } + } + + var availableProviders: [ConnectionProvider] { + self.orderedProviders.filter { !self.providerEnabled($0) } + } + + func ensureSelection() { + guard let selected = self.selectedProvider else { + self.selectedProvider = self.orderedProviders.first + return + } + if !self.orderedProviders.contains(selected) { + self.selectedProvider = self.orderedProviders.first + } + } + + func providerEnabled(_ provider: ConnectionProvider) -> Bool { + switch provider { + case .whatsapp: + guard let status = self.store.snapshot?.whatsapp else { return false } + return status.configured || status.linked || status.running + case .telegram: + guard let status = self.store.snapshot?.telegram else { return false } + return status.configured || status.running + case .discord: + guard let status = self.store.snapshot?.discord else { return false } + return status.configured || status.running + case .signal: + guard let status = self.store.snapshot?.signal else { return false } + return status.configured || status.running + case .imessage: + guard let status = self.store.snapshot?.imessage else { return false } + return status.configured || status.running + } + } + + @ViewBuilder + func providerSection(_ provider: ConnectionProvider) -> some View { + switch provider { + case .whatsapp: + self.whatsAppSection + case .telegram: + self.telegramSection + case .discord: + self.discordSection + case .signal: + self.signalSection + case .imessage: + self.imessageSection + } + } + + func providerTint(_ provider: ConnectionProvider) -> Color { + switch provider { + case .whatsapp: + self.whatsAppTint + case .telegram: + self.telegramTint + case .discord: + self.discordTint + case .signal: + self.signalTint + case .imessage: + self.imessageTint + } + } + + func providerSummary(_ provider: ConnectionProvider) -> String { + switch provider { + case .whatsapp: + self.whatsAppSummary + case .telegram: + self.telegramSummary + case .discord: + self.discordSummary + case .signal: + self.signalSummary + case .imessage: + self.imessageSummary + } + } + + func providerDetails(_ provider: ConnectionProvider) -> String? { + switch provider { + case .whatsapp: + self.whatsAppDetails + case .telegram: + self.telegramDetails + case .discord: + self.discordDetails + case .signal: + self.signalDetails + case .imessage: + self.imessageDetails + } + } + + func providerLastCheckText(_ provider: ConnectionProvider) -> String { + guard let date = self.providerLastCheck(provider) else { return "never" } + return relativeAge(from: date) + } + + func providerLastCheck(_ provider: ConnectionProvider) -> Date? { + switch provider { + case .whatsapp: + guard let status = self.store.snapshot?.whatsapp else { return nil } + return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt) + case .telegram: + return self.date(fromMs: self.store.snapshot?.telegram.lastProbeAt) + case .discord: + return self.date(fromMs: self.store.snapshot?.discord?.lastProbeAt) + case .signal: + return self.date(fromMs: self.store.snapshot?.signal?.lastProbeAt) + case .imessage: + return self.date(fromMs: self.store.snapshot?.imessage?.lastProbeAt) + } + } + + func providerHasError(_ provider: ConnectionProvider) -> Bool { + switch provider { + case .whatsapp: + guard let status = self.store.snapshot?.whatsapp else { return false } + return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true + case .telegram: + guard let status = self.store.snapshot?.telegram else { return false } + return status.lastError?.isEmpty == false || status.probe?.ok == false + case .discord: + guard let status = self.store.snapshot?.discord else { return false } + return status.lastError?.isEmpty == false || status.probe?.ok == false + case .signal: + guard let status = self.store.snapshot?.signal else { return false } + return status.lastError?.isEmpty == false || status.probe?.ok == false + case .imessage: + guard let status = self.store.snapshot?.imessage else { return false } + return status.lastError?.isEmpty == false || status.probe?.ok == false + } + } +} diff --git a/apps/macos/Sources/Clawdbot/ConnectionsSettings+View.swift b/apps/macos/Sources/Clawdbot/ConnectionsSettings+View.swift new file mode 100644 index 000000000..e298276f5 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/ConnectionsSettings+View.swift @@ -0,0 +1,141 @@ +import SwiftUI + +extension ConnectionsSettings { + var body: some View { + NavigationSplitView { + self.sidebar + } detail: { + self.detail + } + .onAppear { + self.store.start() + self.ensureSelection() + } + .onChange(of: self.orderedProviders) { _, _ in + self.ensureSelection() + } + .onDisappear { self.store.stop() } + } + + private var sidebar: some View { + List(selection: self.$selectedProvider) { + if !self.enabledProviders.isEmpty { + Section("Configured") { + ForEach(self.enabledProviders) { provider in + self.sidebarRow(provider) + .tag(provider) + } + } + } + + if !self.availableProviders.isEmpty { + Section("Available") { + ForEach(self.availableProviders) { provider in + self.sidebarRow(provider) + .tag(provider) + } + } + } + } + .listStyle(.sidebar) + .frame(minWidth: 210, idealWidth: 230, maxWidth: 260) + } + + private var detail: some View { + Group { + if let provider = self.selectedProvider { + self.providerDetail(provider) + } else { + self.emptyDetail + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var emptyDetail: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Connections") + .font(.title3.weight(.semibold)) + Text("Select a provider to view status and settings.") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 24) + .padding(.vertical, 18) + } + + private func providerDetail(_ provider: ConnectionProvider) -> some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 16) { + self.detailHeader(for: provider) + Divider() + self.providerSection(provider) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.vertical, 18) + } + } + + private func sidebarRow(_ provider: ConnectionProvider) -> some View { + HStack(spacing: 8) { + Circle() + .fill(self.providerTint(provider)) + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 2) { + Text(provider.title) + Text(self.providerSummary(provider)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + private func detailHeader(for provider: ConnectionProvider) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Label(provider.detailTitle, systemImage: provider.systemImage) + .font(.title3.weight(.semibold)) + self.statusBadge( + self.providerSummary(provider), + color: self.providerTint(provider)) + Spacer() + self.providerHeaderActions(provider) + } + + HStack(spacing: 10) { + Text("Last check \(self.providerLastCheckText(provider))") + .font(.caption) + .foregroundStyle(.secondary) + if self.providerHasError(provider) { + Text("Error") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.red.opacity(0.15)) + .foregroundStyle(.red) + .clipShape(Capsule()) + } + } + + if let details = self.providerDetails(provider) { + Text(details) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func statusBadge(_ text: String, color: Color) -> some View { + Text(text) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(color.opacity(0.16)) + .foregroundStyle(color) + .clipShape(Capsule()) + } +} diff --git a/apps/macos/Sources/Clawdbot/ConnectionsSettings.swift b/apps/macos/Sources/Clawdbot/ConnectionsSettings.swift index 5d6b5f95f..91dea51be 100644 --- a/apps/macos/Sources/Clawdbot/ConnectionsSettings.swift +++ b/apps/macos/Sources/Clawdbot/ConnectionsSettings.swift @@ -2,7 +2,7 @@ import AppKit import SwiftUI struct ConnectionsSettings: View { - private enum ConnectionProvider: String, CaseIterable, Identifiable, Hashable { + enum ConnectionProvider: String, CaseIterable, Identifiable, Hashable { case whatsapp case telegram case discord @@ -53,1243 +53,11 @@ struct ConnectionsSettings: View { } @Bindable var store: ConnectionsStore - @State private var selectedProvider: ConnectionProvider? = nil - @State private var showTelegramToken = false - @State private var showDiscordToken = false + @State var selectedProvider: ConnectionProvider? + @State var showTelegramToken = false + @State var showDiscordToken = false init(store: ConnectionsStore = .shared) { self.store = store } - - var body: some View { - NavigationSplitView { - self.sidebar - } detail: { - self.detail - } - .onAppear { - self.store.start() - self.ensureSelection() - } - .onChange(of: self.orderedProviders) { _, _ in - self.ensureSelection() - } - .onDisappear { self.store.stop() } - } - - private var sidebar: some View { - List(selection: self.$selectedProvider) { - if !self.enabledProviders.isEmpty { - Section("Configured") { - ForEach(self.enabledProviders) { provider in - self.sidebarRow(provider) - .tag(provider) - } - } - } - - if !self.availableProviders.isEmpty { - Section("Available") { - ForEach(self.availableProviders) { provider in - self.sidebarRow(provider) - .tag(provider) - } - } - } - } - .listStyle(.sidebar) - .frame(minWidth: 210, idealWidth: 230, maxWidth: 260) - } - - private var detail: some View { - Group { - if let provider = self.selectedProvider { - self.providerDetail(provider) - } else { - self.emptyDetail - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - - private var emptyDetail: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Connections") - .font(.title3.weight(.semibold)) - Text("Select a provider to view status and settings.") - .font(.callout) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 24) - .padding(.vertical, 18) - } - - private func providerDetail(_ provider: ConnectionProvider) -> some View { - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 16) { - self.detailHeader(for: provider) - Divider() - self.providerSection(provider) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) - .padding(.vertical, 18) - } - } - - private func sidebarRow(_ provider: ConnectionProvider) -> some View { - HStack(spacing: 8) { - Circle() - .fill(self.providerTint(provider)) - .frame(width: 8, height: 8) - VStack(alignment: .leading, spacing: 2) { - Text(provider.title) - Text(self.providerSummary(provider)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - } - - private func detailHeader(for provider: ConnectionProvider) -> some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Label(provider.detailTitle, systemImage: provider.systemImage) - .font(.title3.weight(.semibold)) - self.statusBadge( - self.providerSummary(provider), - color: self.providerTint(provider)) - Spacer() - self.providerHeaderActions(provider) - } - - HStack(spacing: 10) { - Text("Last check \(self.providerLastCheckText(provider))") - .font(.caption) - .foregroundStyle(.secondary) - if self.providerHasError(provider) { - Text("Error") - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.red.opacity(0.15)) - .foregroundStyle(.red) - .clipShape(Capsule()) - } - } - - if let details = self.providerDetails(provider) { - Text(details) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - private func statusBadge(_ text: String, color: Color) -> some View { - Text(text) - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(color.opacity(0.16)) - .foregroundStyle(color) - .clipShape(Capsule()) - } - - private func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View { - GroupBox(title) { - VStack(alignment: .leading, spacing: 10) { - content() - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - @ViewBuilder - private func providerHeaderActions(_ provider: ConnectionProvider) -> some View { - HStack(spacing: 8) { - if provider == .whatsapp { - Button("Logout") { - Task { await self.store.logoutWhatsApp() } - } - .buttonStyle(.bordered) - .disabled(self.store.whatsappBusy) - } - - if provider == .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) - } - - private 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) - } - } - } - - private 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) - } - } - - private 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($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) - } - } - - private 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) - } - } - - private 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) - } - } - - private var whatsAppTint: Color { - guard let status = self.store.snapshot?.whatsapp else { return .secondary } - if !status.configured { return .secondary } - if !status.linked { return .red } - if status.lastError != nil { return .orange } - if status.connected { return .green } - if status.running { return .orange } - return .orange - } - - private var telegramTint: Color { - guard let status = self.store.snapshot?.telegram else { return .secondary } - if !status.configured { return .secondary } - if status.lastError != nil { return .orange } - if status.probe?.ok == false { return .orange } - if status.running { return .green } - return .orange - } - - private var discordTint: Color { - guard let status = self.store.snapshot?.discord else { return .secondary } - if !status.configured { return .secondary } - if status.lastError != nil { return .orange } - if status.probe?.ok == false { return .orange } - if status.running { return .green } - return .orange - } - - private var signalTint: Color { - guard let status = self.store.snapshot?.signal else { return .secondary } - if !status.configured { return .secondary } - if status.lastError != nil { return .orange } - if status.probe?.ok == false { return .orange } - if status.running { return .green } - return .orange - } - - private var imessageTint: Color { - guard let status = self.store.snapshot?.imessage else { return .secondary } - if !status.configured { return .secondary } - if status.lastError != nil { return .orange } - if status.probe?.ok == false { return .orange } - if status.running { return .green } - return .orange - } - - private var whatsAppSummary: String { - guard let status = self.store.snapshot?.whatsapp else { return "Checking…" } - if !status.linked { return "Not linked" } - if status.connected { return "Connected" } - if status.running { return "Running" } - return "Linked" - } - - private var telegramSummary: String { - guard let status = self.store.snapshot?.telegram else { return "Checking…" } - if !status.configured { return "Not configured" } - if status.running { return "Running" } - return "Configured" - } - - private var discordSummary: String { - guard let status = self.store.snapshot?.discord else { return "Checking…" } - if !status.configured { return "Not configured" } - if status.running { return "Running" } - return "Configured" - } - - private var signalSummary: String { - guard let status = self.store.snapshot?.signal else { return "Checking…" } - if !status.configured { return "Not configured" } - if status.running { return "Running" } - return "Configured" - } - - private var imessageSummary: String { - guard let status = self.store.snapshot?.imessage else { return "Checking…" } - if !status.configured { return "Not configured" } - if status.running { return "Running" } - return "Configured" - } - - private var whatsAppDetails: String? { - guard let status = self.store.snapshot?.whatsapp else { return nil } - var lines: [String] = [] - if let e164 = status.`self`?.e164 ?? status.`self`?.jid { - lines.append("Linked as \(e164)") - } - if let age = status.authAgeMs { - lines.append("Auth age \(msToAge(age))") - } - if let last = self.date(fromMs: status.lastConnectedAt) { - lines.append("Last connect \(relativeAge(from: last))") - } - if let disconnect = status.lastDisconnect { - let when = self.date(fromMs: disconnect.at).map { relativeAge(from: $0) } ?? "unknown" - let code = disconnect.status.map { "status \($0)" } ?? "status unknown" - let err = disconnect.error ?? "disconnect" - lines.append("Last disconnect \(code) · \(err) · \(when)") - } - if status.reconnectAttempts > 0 { - lines.append("Reconnect attempts \(status.reconnectAttempts)") - } - if let msgAt = self.date(fromMs: status.lastMessageAt) { - lines.append("Last message \(relativeAge(from: msgAt))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") - } - - private var telegramDetails: String? { - guard let status = self.store.snapshot?.telegram else { return nil } - var lines: [String] = [] - if let source = status.tokenSource { - lines.append("Token source: \(source)") - } - if let mode = status.mode { - lines.append("Mode: \(mode)") - } - if let probe = status.probe { - if probe.ok { - if let name = probe.bot?.username { - lines.append("Bot: @\(name)") - } - if let url = probe.webhook?.url, !url.isEmpty { - lines.append("Webhook: \(url)") - } - } else { - let code = probe.status.map { String($0) } ?? "unknown" - lines.append("Probe failed (\(code))") - } - } - if let last = self.date(fromMs: status.lastProbeAt) { - lines.append("Last probe \(relativeAge(from: last))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") - } - - private var discordDetails: String? { - guard let status = self.store.snapshot?.discord else { return nil } - var lines: [String] = [] - if let source = status.tokenSource { - lines.append("Token source: \(source)") - } - if let probe = status.probe { - if probe.ok { - if let name = probe.bot?.username { - lines.append("Bot: @\(name)") - } - if let elapsed = probe.elapsedMs { - lines.append("Probe \(Int(elapsed))ms") - } - } else { - let code = probe.status.map { String($0) } ?? "unknown" - lines.append("Probe failed (\(code))") - } - } - if let last = self.date(fromMs: status.lastProbeAt) { - lines.append("Last probe \(relativeAge(from: last))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") - } - - private var signalDetails: String? { - guard let status = self.store.snapshot?.signal else { return nil } - var lines: [String] = [] - lines.append("Base URL: \(status.baseUrl)") - if let probe = status.probe { - if probe.ok { - if let version = probe.version, !version.isEmpty { - lines.append("Version \(version)") - } - if let elapsed = probe.elapsedMs { - lines.append("Probe \(Int(elapsed))ms") - } - } else { - let code = probe.status.map { String($0) } ?? "unknown" - lines.append("Probe failed (\(code))") - } - } - if let last = self.date(fromMs: status.lastProbeAt) { - lines.append("Last probe \(relativeAge(from: last))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") - } - - private var imessageDetails: String? { - guard let status = self.store.snapshot?.imessage else { return nil } - var lines: [String] = [] - if let cliPath = status.cliPath, !cliPath.isEmpty { - lines.append("CLI: \(cliPath)") - } - if let dbPath = status.dbPath, !dbPath.isEmpty { - lines.append("DB: \(dbPath)") - } - if let probe = status.probe, !probe.ok { - let err = probe.error ?? "probe failed" - lines.append("Probe error: \(err)") - } - if let last = self.date(fromMs: status.lastProbeAt) { - lines.append("Last probe \(relativeAge(from: last))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") - } - - private var isTelegramTokenLocked: Bool { - self.store.snapshot?.telegram.tokenSource == "env" - } - - private var isDiscordTokenLocked: Bool { - self.store.snapshot?.discord?.tokenSource == "env" - } - - private var orderedProviders: [ConnectionProvider] { - ConnectionProvider.allCases.sorted { lhs, rhs in - let lhsEnabled = self.providerEnabled(lhs) - let rhsEnabled = self.providerEnabled(rhs) - if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled } - return lhs.sortOrder < rhs.sortOrder - } - } - - private var enabledProviders: [ConnectionProvider] { - self.orderedProviders.filter { self.providerEnabled($0) } - } - - private var availableProviders: [ConnectionProvider] { - self.orderedProviders.filter { !self.providerEnabled($0) } - } - - private func ensureSelection() { - guard let selected = self.selectedProvider else { - self.selectedProvider = self.orderedProviders.first - return - } - if !self.orderedProviders.contains(selected) { - self.selectedProvider = self.orderedProviders.first - } - } - - private func providerEnabled(_ provider: ConnectionProvider) -> Bool { - switch provider { - case .whatsapp: - guard let status = self.store.snapshot?.whatsapp else { return false } - return status.configured || status.linked || status.running - case .telegram: - guard let status = self.store.snapshot?.telegram else { return false } - return status.configured || status.running - case .discord: - guard let status = self.store.snapshot?.discord else { return false } - return status.configured || status.running - case .signal: - guard let status = self.store.snapshot?.signal else { return false } - return status.configured || status.running - case .imessage: - guard let status = self.store.snapshot?.imessage else { return false } - return status.configured || status.running - } - } - - @ViewBuilder - private func providerSection(_ provider: ConnectionProvider) -> some View { - switch provider { - case .whatsapp: - self.whatsAppSection - case .telegram: - self.telegramSection - case .discord: - self.discordSection - case .signal: - self.signalSection - case .imessage: - self.imessageSection - } - } - - @ViewBuilder - private var configStatusMessage: some View { - if let status = self.store.configStatus { - Text(status) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - private func providerTint(_ provider: ConnectionProvider) -> Color { - switch provider { - case .whatsapp: - self.whatsAppTint - case .telegram: - self.telegramTint - case .discord: - self.discordTint - case .signal: - self.signalTint - case .imessage: - self.imessageTint - } - } - - private func providerSummary(_ provider: ConnectionProvider) -> String { - switch provider { - case .whatsapp: - self.whatsAppSummary - case .telegram: - self.telegramSummary - case .discord: - self.discordSummary - case .signal: - self.signalSummary - case .imessage: - self.imessageSummary - } - } - - private func providerDetails(_ provider: ConnectionProvider) -> String? { - switch provider { - case .whatsapp: - self.whatsAppDetails - case .telegram: - self.telegramDetails - case .discord: - self.discordDetails - case .signal: - self.signalDetails - case .imessage: - self.imessageDetails - } - } - - private func providerLastCheckText(_ provider: ConnectionProvider) -> String { - guard let date = self.providerLastCheck(provider) else { return "never" } - return relativeAge(from: date) - } - - private func providerLastCheck(_ provider: ConnectionProvider) -> Date? { - switch provider { - case .whatsapp: - guard let status = self.store.snapshot?.whatsapp else { return nil } - return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt) - case .telegram: - return self.date(fromMs: self.store.snapshot?.telegram.lastProbeAt) - case .discord: - return self.date(fromMs: self.store.snapshot?.discord?.lastProbeAt) - case .signal: - return self.date(fromMs: self.store.snapshot?.signal?.lastProbeAt) - case .imessage: - return self.date(fromMs: self.store.snapshot?.imessage?.lastProbeAt) - } - } - - private func providerHasError(_ provider: ConnectionProvider) -> Bool { - switch provider { - case .whatsapp: - guard let status = self.store.snapshot?.whatsapp else { return false } - return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true - case .telegram: - guard let status = self.store.snapshot?.telegram else { return false } - return status.lastError?.isEmpty == false || status.probe?.ok == false - case .discord: - guard let status = self.store.snapshot?.discord else { return false } - return status.lastError?.isEmpty == false || status.probe?.ok == false - case .signal: - guard let status = self.store.snapshot?.signal else { return false } - return status.lastError?.isEmpty == false || status.probe?.ok == false - case .imessage: - guard let status = self.store.snapshot?.imessage else { return false } - return status.lastError?.isEmpty == false || status.probe?.ok == false - } - } - - private func gridLabel(_ text: String) -> some View { - Text(text) - .font(.callout.weight(.semibold)) - .frame(width: 140, alignment: .leading) - } - - private func date(fromMs ms: Double?) -> Date? { - guard let ms else { return nil } - return Date(timeIntervalSince1970: ms / 1000) - } - - private func qrImage(from dataUrl: String) -> NSImage? { - guard let comma = dataUrl.firstIndex(of: ",") else { return nil } - let header = dataUrl[.. [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 = snap.config?["signal"]?.dictionaryValue + 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 = snap.config?["imessage"]?.dictionaryValue + 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"]) + } + + 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] = (self.configRoot["telegram"] as? [String: Any]) ?? [:] + let token = self.trimmed(self.telegramToken) + if token.isEmpty { + telegram.removeValue(forKey: "botToken") + } else { + telegram["botToken"] = token + } + + telegram["requireMention"] = self.telegramRequireMention + + let allow = self.splitCsv(self.telegramAllowFrom) + if allow.isEmpty { + telegram.removeValue(forKey: "allowFrom") + } else { + telegram["allowFrom"] = allow + } + + self.setOptionalString(&telegram, key: "proxy", value: self.telegramProxy) + self.setOptionalString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl) + self.setOptionalString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret) + self.setOptionalString(&telegram, key: "webhookPath", value: self.telegramWebhookPath) + + self.setSection("telegram", payload: telegram) + await self.persistConfig() + } + + func saveDiscordConfig() async { + guard !self.isSavingConfig else { return } + self.isSavingConfig = true + defer { self.isSavingConfig = false } + if !self.configLoaded { + await self.loadConfig() + } + + let discord = self.buildDiscordConfig() + self.setSection("discord", payload: discord) + await self.persistConfig() + } + + 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.configRoot["signal"] as? [String: Any]) ?? [:] + if self.signalEnabled { + signal.removeValue(forKey: "enabled") + } else { + signal["enabled"] = false + } + + self.setOptionalString(&signal, key: "account", value: self.signalAccount) + self.setOptionalString(&signal, key: "httpUrl", value: self.signalHttpUrl) + self.setOptionalString(&signal, key: "httpHost", value: self.signalHttpHost) + self.setOptionalNumber(&signal, key: "httpPort", value: self.signalHttpPort) + self.setOptionalString(&signal, key: "cliPath", value: self.signalCliPath) + + if self.signalAutoStart { + signal.removeValue(forKey: "autoStart") + } else { + signal["autoStart"] = false + } + + self.setOptionalString(&signal, key: "receiveMode", value: self.signalReceiveMode) + + self.setOptionalBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments) + self.setOptionalBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories) + self.setOptionalBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts) + + let allow = self.splitCsv(self.signalAllowFrom) + if allow.isEmpty { + signal.removeValue(forKey: "allowFrom") + } else { + signal["allowFrom"] = allow + } + + self.setOptionalNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb) + + self.setSection("signal", payload: signal) + await self.persistConfig() + } + + 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.configRoot["imessage"] as? [String: Any]) ?? [:] + if self.imessageEnabled { + imessage.removeValue(forKey: "enabled") + } else { + imessage["enabled"] = false + } + + self.setOptionalString(&imessage, key: "cliPath", value: self.imessageCliPath) + self.setOptionalString(&imessage, key: "dbPath", value: self.imessageDbPath) + + let service = self.trimmed(self.imessageService) + if service.isEmpty || service == "auto" { + imessage.removeValue(forKey: "service") + } else { + imessage["service"] = service + } + + self.setOptionalString(&imessage, key: "region", value: self.imessageRegion) + + let allow = self.splitCsv(self.imessageAllowFrom) + if allow.isEmpty { + imessage.removeValue(forKey: "allowFrom") + } else { + imessage["allowFrom"] = allow + } + + self.setOptionalBool(&imessage, key: "includeAttachments", value: self.imessageIncludeAttachments) + self.setOptionalNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb) + + self.setSection("imessage", payload: imessage) + await self.persistConfig() + } + + private func buildDiscordConfig() -> [String: Any] { + var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:] + if self.discordEnabled { + discord.removeValue(forKey: "enabled") + } else { + discord["enabled"] = false + } + self.setOptionalString(&discord, key: "token", value: self.discordToken) + + if let dm = self.buildDiscordDmConfig(base: discord["dm"] as? [String: Any] ?? [:]) { + discord["dm"] = dm + } else { + discord.removeValue(forKey: "dm") + } + + self.setOptionalNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb) + self.setOptionalInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true) + self.setOptionalInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false) + + let replyToMode = self.trimmed(self.discordReplyToMode) + if replyToMode.isEmpty || replyToMode == "off" { + discord.removeValue(forKey: "replyToMode") + } else if ["first", "all"].contains(replyToMode) { + discord["replyToMode"] = replyToMode + } else { + discord.removeValue(forKey: "replyToMode") + } + + if let guilds = self.buildDiscordGuildsConfig() { + discord["guilds"] = guilds + } else { + discord.removeValue(forKey: "guilds") + } + + if let actions = self.buildDiscordActionsConfig(base: discord["actions"] as? [String: Any] ?? [:]) { + discord["actions"] = actions + } else { + discord.removeValue(forKey: "actions") + } + + if let slash = self.buildDiscordSlashConfig(base: discord["slashCommand"] as? [String: Any] ?? [:]) { + discord["slashCommand"] = slash + } else { + discord.removeValue(forKey: "slashCommand") + } + + return discord + } + + private func buildDiscordDmConfig(base: [String: Any]) -> [String: Any]? { + var dm = base + if self.discordDmEnabled { + dm.removeValue(forKey: "enabled") + } else { + dm["enabled"] = false + } + let allow = self.splitCsv(self.discordAllowFrom) + if allow.isEmpty { + dm.removeValue(forKey: "allowFrom") + } else { + dm["allowFrom"] = allow + } + + if self.discordGroupEnabled { + dm["groupEnabled"] = true + } else { + dm.removeValue(forKey: "groupEnabled") + } + + let groupChannels = self.splitCsv(self.discordGroupChannels) + if groupChannels.isEmpty { + dm.removeValue(forKey: "groupChannels") + } else { + dm["groupChannels"] = groupChannels + } + + return dm.isEmpty ? nil : dm + } + + private func buildDiscordGuildsConfig() -> [String: Any]? { + let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in + let key = self.trimmed(entry.key) + guard !key.isEmpty else { return } + var payload: [String: Any] = [:] + let slug = self.trimmed(entry.slug) + if !slug.isEmpty { payload["slug"] = slug } + if entry.requireMention { payload["requireMention"] = true } + if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) { + payload["reactionNotifications"] = entry.reactionNotifications + } + let users = self.splitCsv(entry.users) + if !users.isEmpty { payload["users"] = users } + let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in + let channelKey = self.trimmed(channel.key) + guard !channelKey.isEmpty else { return } + var channelPayload: [String: Any] = [:] + if !channel.allow { channelPayload["allow"] = false } + if channel.requireMention { channelPayload["requireMention"] = true } + channelsResult[channelKey] = channelPayload + } + if !channels.isEmpty { payload["channels"] = channels } + result[key] = payload + } + return guilds.isEmpty ? nil : guilds + } + + private func buildDiscordActionsConfig(base: [String: Any]) -> [String: Any]? { + var actions = base + 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 buildDiscordSlashConfig(base: [String: Any]) -> [String: Any]? { + var slash = base + if self.discordSlashEnabled { + slash["enabled"] = true + } else { + slash.removeValue(forKey: "enabled") + } + self.setOptionalString(&slash, key: "name", value: self.discordSlashName) + self.setOptionalString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix) + if self.discordSlashEphemeral { + slash.removeValue(forKey: "ephemeral") + } else { + slash["ephemeral"] = false + } + return slash.isEmpty ? nil : slash + } + + private func persistConfig() async { + do { + let data = try JSONSerialization.data( + withJSONObject: self.configRoot, + 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)] + _ = try await GatewayConnection.shared.requestRaw( + method: .configSet, + params: params, + timeoutMs: 10000) + self.configStatus = "Saved to ~/.clawdbot/clawdbot.json." + await self.refresh(probe: true) + } catch { + self.configStatus = error.localizedDescription + } + } + + private func setSection(_ key: String, payload: [String: Any]) { + if payload.isEmpty { + self.configRoot.removeValue(forKey: key) + } else { + self.configRoot[key] = payload + } + } + + 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 setOptionalString(_ target: inout [String: Any], key: String, value: String) { + let trimmed = self.trimmed(value) + if trimmed.isEmpty { + target.removeValue(forKey: key) + } else { + target[key] = trimmed + } + } + + private func setOptionalNumber(_ target: inout [String: Any], key: String, value: String) { + let trimmed = self.trimmed(value) + if trimmed.isEmpty { + target.removeValue(forKey: key) + } else if let number = Double(trimmed) { + target[key] = number + } + } + + private func setOptionalInt( + _ target: inout [String: Any], + key: String, + value: String, + allowZero: Bool) + { + let trimmed = self.trimmed(value) + if trimmed.isEmpty { + target.removeValue(forKey: key) + return + } + guard let number = Int(trimmed) else { + target.removeValue(forKey: key) + return + } + let isValid = allowZero ? number >= 0 : number > 0 + guard isValid else { + target.removeValue(forKey: key) + return + } + target[key] = number + } + + private func setOptionalBool(_ target: inout [String: Any], key: String, value: Bool) { + if value { + target[key] = true + } else { + target.removeValue(forKey: key) + } + } + + private func setAction( + _ actions: inout [String: Any], + key: String, + value: Bool, + defaultValue: Bool) + { + if value == defaultValue { + actions.removeValue(forKey: key) + } else { + actions[key] = value + } + } +} diff --git a/apps/macos/Sources/Clawdbot/ConnectionsStore+Lifecycle.swift b/apps/macos/Sources/Clawdbot/ConnectionsStore+Lifecycle.swift new file mode 100644 index 000000000..7227330aa --- /dev/null +++ b/apps/macos/Sources/Clawdbot/ConnectionsStore+Lifecycle.swift @@ -0,0 +1,158 @@ +import ClawdbotProtocol +import Foundation + +extension ConnectionsStore { + 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.loadConfig() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) + await self.refresh(probe: false) + } + } + } + + func stop() { + self.pollTask?.cancel() + self.pollTask = nil + } + + func refresh(probe: Bool) async { + guard !self.isRefreshing else { return } + self.isRefreshing = true + defer { self.isRefreshing = false } + + do { + let params: [String: AnyCodable] = [ + "probe": AnyCodable(probe), + "timeoutMs": AnyCodable(8000), + ] + let snap: ProvidersStatusSnapshot = try await GatewayConnection.shared.requestDecoded( + method: .providersStatus, + params: params, + timeoutMs: 12000) + self.snapshot = snap + self.lastSuccess = Date() + self.lastError = nil + } catch { + self.lastError = error.localizedDescription + } + } + + func startWhatsAppLogin(force: Bool, autoWait: Bool = true) async { + guard !self.whatsappBusy else { return } + self.whatsappBusy = true + defer { self.whatsappBusy = false } + var shouldAutoWait = false + do { + let params: [String: AnyCodable] = [ + "force": AnyCodable(force), + "timeoutMs": AnyCodable(30000), + ] + let result: WhatsAppLoginStartResult = try await GatewayConnection.shared.requestDecoded( + method: .webLoginStart, + params: params, + timeoutMs: 35000) + self.whatsappLoginMessage = result.message + self.whatsappLoginQrDataUrl = result.qrDataUrl + self.whatsappLoginConnected = nil + shouldAutoWait = autoWait && result.qrDataUrl != nil + } catch { + self.whatsappLoginMessage = error.localizedDescription + self.whatsappLoginQrDataUrl = nil + self.whatsappLoginConnected = nil + } + await self.refresh(probe: true) + if shouldAutoWait { + Task { await self.waitWhatsAppLogin() } + } + } + + func waitWhatsAppLogin(timeoutMs: Int = 120_000) async { + guard !self.whatsappBusy else { return } + self.whatsappBusy = true + defer { self.whatsappBusy = false } + do { + let params: [String: AnyCodable] = [ + "timeoutMs": AnyCodable(timeoutMs), + ] + let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded( + method: .webLoginWait, + params: params, + timeoutMs: Double(timeoutMs) + 5000) + self.whatsappLoginMessage = result.message + self.whatsappLoginConnected = result.connected + if result.connected { + self.whatsappLoginQrDataUrl = nil + } + } catch { + self.whatsappLoginMessage = error.localizedDescription + } + await self.refresh(probe: true) + } + + func logoutWhatsApp() async { + guard !self.whatsappBusy else { return } + self.whatsappBusy = true + defer { self.whatsappBusy = false } + do { + let result: WhatsAppLogoutResult = try await GatewayConnection.shared.requestDecoded( + method: .webLogout, + params: nil, + timeoutMs: 15000) + self.whatsappLoginMessage = result.cleared + ? "Logged out and cleared credentials." + : "No WhatsApp session found." + self.whatsappLoginQrDataUrl = nil + } catch { + self.whatsappLoginMessage = error.localizedDescription + } + await self.refresh(probe: true) + } + + func logoutTelegram() async { + guard !self.telegramBusy else { return } + self.telegramBusy = true + defer { self.telegramBusy = false } + do { + let result: TelegramLogoutResult = try await GatewayConnection.shared.requestDecoded( + method: .telegramLogout, + params: nil, + timeoutMs: 15000) + if result.envToken == true { + self.configStatus = "Telegram token still set via env; config cleared." + } else { + self.configStatus = result.cleared + ? "Telegram token cleared." + : "No Telegram token configured." + } + await self.loadConfig() + } catch { + self.configStatus = error.localizedDescription + } + await self.refresh(probe: true) + } +} + +private struct WhatsAppLoginStartResult: Codable { + let qrDataUrl: String? + let message: String +} + +private struct WhatsAppLoginWaitResult: Codable { + let connected: Bool + let message: String +} + +private struct WhatsAppLogoutResult: Codable { + let cleared: Bool +} + +private struct TelegramLogoutResult: Codable { + let cleared: Bool + let envToken: Bool? +} diff --git a/apps/macos/Sources/Clawdbot/ConnectionsStore.swift b/apps/macos/Sources/Clawdbot/ConnectionsStore.swift index 2246d4137..455036356 100644 --- a/apps/macos/Sources/Clawdbot/ConnectionsStore.swift +++ b/apps/macos/Sources/Clawdbot/ConnectionsStore.swift @@ -172,8 +172,8 @@ struct DiscordGuildForm: Identifiable { requireMention: Bool = false, reactionNotifications: String = "own", users: String = "", - channels: [DiscordGuildChannelForm] = [] - ) { + channels: [DiscordGuildChannelForm] = []) + { self.key = key self.slug = slug self.requireMention = requireMention @@ -260,887 +260,13 @@ final class ConnectionsStore { var configStatus: String? var isSavingConfig = false - private let interval: TimeInterval = 45 - private let isPreview: Bool - private var pollTask: Task? - private var configRoot: [String: Any] = [:] - private var configLoaded = false + let interval: TimeInterval = 45 + let isPreview: Bool + var pollTask: Task? + var configRoot: [String: Any] = [:] + var configLoaded = false init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { self.isPreview = isPreview } - - 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.loadConfig() - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) - await self.refresh(probe: false) - } - } - } - - func stop() { - self.pollTask?.cancel() - self.pollTask = nil - } - - func refresh(probe: Bool) async { - guard !self.isRefreshing else { return } - self.isRefreshing = true - defer { self.isRefreshing = false } - - do { - let params: [String: AnyCodable] = [ - "probe": AnyCodable(probe), - "timeoutMs": AnyCodable(8000), - ] - let snap: ProvidersStatusSnapshot = try await GatewayConnection.shared.requestDecoded( - method: .providersStatus, - params: params, - timeoutMs: 12000) - self.snapshot = snap - self.lastSuccess = Date() - self.lastError = nil - } catch { - self.lastError = error.localizedDescription - } - } - - func startWhatsAppLogin(force: Bool, autoWait: Bool = true) async { - guard !self.whatsappBusy else { return } - self.whatsappBusy = true - defer { self.whatsappBusy = false } - var shouldAutoWait = false - do { - let params: [String: AnyCodable] = [ - "force": AnyCodable(force), - "timeoutMs": AnyCodable(30000), - ] - let result: WhatsAppLoginStartResult = try await GatewayConnection.shared.requestDecoded( - method: .webLoginStart, - params: params, - timeoutMs: 35000) - self.whatsappLoginMessage = result.message - self.whatsappLoginQrDataUrl = result.qrDataUrl - self.whatsappLoginConnected = nil - shouldAutoWait = autoWait && result.qrDataUrl != nil - } catch { - self.whatsappLoginMessage = error.localizedDescription - self.whatsappLoginQrDataUrl = nil - self.whatsappLoginConnected = nil - } - await self.refresh(probe: true) - if shouldAutoWait { - Task { await self.waitWhatsAppLogin() } - } - } - - func waitWhatsAppLogin(timeoutMs: Int = 120_000) async { - guard !self.whatsappBusy else { return } - self.whatsappBusy = true - defer { self.whatsappBusy = false } - do { - let params: [String: AnyCodable] = [ - "timeoutMs": AnyCodable(timeoutMs), - ] - let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded( - method: .webLoginWait, - params: params, - timeoutMs: Double(timeoutMs) + 5000) - self.whatsappLoginMessage = result.message - self.whatsappLoginConnected = result.connected - if result.connected { - self.whatsappLoginQrDataUrl = nil - } - } catch { - self.whatsappLoginMessage = error.localizedDescription - } - await self.refresh(probe: true) - } - - func logoutWhatsApp() async { - guard !self.whatsappBusy else { return } - self.whatsappBusy = true - defer { self.whatsappBusy = false } - do { - let result: WhatsAppLogoutResult = try await GatewayConnection.shared.requestDecoded( - method: .webLogout, - params: nil, - timeoutMs: 15000) - self.whatsappLoginMessage = result.cleared - ? "Logged out and cleared credentials." - : "No WhatsApp session found." - self.whatsappLoginQrDataUrl = nil - } catch { - self.whatsappLoginMessage = error.localizedDescription - } - await self.refresh(probe: true) - } - - func logoutTelegram() async { - guard !self.telegramBusy else { return } - self.telegramBusy = true - defer { self.telegramBusy = false } - do { - let result: TelegramLogoutResult = try await GatewayConnection.shared.requestDecoded( - method: .telegramLogout, - params: nil, - timeoutMs: 15000) - if result.envToken == true { - self.configStatus = "Telegram token still set via env; config cleared." - } else { - self.configStatus = result.cleared - ? "Telegram token cleared." - : "No Telegram token configured." - } - await self.loadConfig() - } catch { - self.configStatus = error.localizedDescription - } - await self.refresh(probe: true) - } - - 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.configLoaded = true - - let ui = snap.config?["ui"]?.dictionaryValue - let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam - - let telegram = snap.config?["telegram"]?.dictionaryValue - self.telegramToken = telegram?["botToken"]?.stringValue ?? "" - self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true - if let allow = telegram?["allowFrom"]?.arrayValue { - let strings = allow.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 - } - self.telegramAllowFrom = strings.joined(separator: ", ") - } else { - self.telegramAllowFrom = "" - } - self.telegramProxy = telegram?["proxy"]?.stringValue ?? "" - self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? "" - self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? "" - self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? "" - - let discord = snap.config?["discord"]?.dictionaryValue - self.discordEnabled = discord?["enabled"]?.boolValue ?? true - self.discordToken = discord?["token"]?.stringValue ?? "" - let discordDm = discord?["dm"]?.dictionaryValue - self.discordDmEnabled = discordDm?["enabled"]?.boolValue ?? true - if let allow = discordDm?["allowFrom"]?.arrayValue { - let strings = allow.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 - } - self.discordAllowFrom = strings.joined(separator: ", ") - } else { - self.discordAllowFrom = "" - } - self.discordGroupEnabled = discordDm?["groupEnabled"]?.boolValue ?? false - if let channels = discordDm?["groupChannels"]?.arrayValue { - let strings = channels.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 - } - self.discordGroupChannels = strings.joined(separator: ", ") - } else { - self.discordGroupChannels = "" - } - if let media = discord?["mediaMaxMb"]?.doubleValue ?? discord?["mediaMaxMb"]?.intValue.map(Double.init) { - self.discordMediaMaxMb = String(Int(media)) - } else { - self.discordMediaMaxMb = "" - } - if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue.map(Double.init) { - self.discordHistoryLimit = String(Int(history)) - } else { - self.discordHistoryLimit = "" - } - if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue.map(Double.init) { - self.discordTextChunkLimit = String(Int(limit)) - } else { - self.discordTextChunkLimit = "" - } - if let mode = discord?["replyToMode"]?.stringValue, ["off", "first", "all"].contains(mode) { - self.discordReplyToMode = mode - } else { - self.discordReplyToMode = "off" - } - if let guilds = discord?["guilds"]?.dictionaryValue { - self.discordGuilds = 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 = entry["users"]?.arrayValue? - .compactMap { item -> String? in - if let str = item.stringValue { return str } - if let intVal = item.intValue { return String(intVal) } - if let doubleVal = item.doubleValue { return String(Int(doubleVal)) } - return nil - } - .joined(separator: ", ") ?? "" - let channels: [DiscordGuildChannelForm] - if let channelMap = entry["channels"]?.dictionaryValue { - channels = 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 { - channels = [] - } - return DiscordGuildForm( - key: key, - slug: slug, - requireMention: requireMention, - reactionNotifications: reactionNotifications, - users: users, - channels: channels) - } - .sorted { $0.key < $1.key } - } else { - self.discordGuilds = [] - } - 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 - - let signal = snap.config?["signal"]?.dictionaryValue - self.signalEnabled = signal?["enabled"]?.boolValue ?? true - self.signalAccount = signal?["account"]?.stringValue ?? "" - self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? "" - self.signalHttpHost = signal?["httpHost"]?.stringValue ?? "" - if let port = signal?["httpPort"]?.doubleValue ?? signal?["httpPort"]?.intValue.map(Double.init) { - self.signalHttpPort = String(Int(port)) - } else { - self.signalHttpPort = "" - } - 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 - if let allow = signal?["allowFrom"]?.arrayValue { - let strings = allow.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 - } - self.signalAllowFrom = strings.joined(separator: ", ") - } else { - self.signalAllowFrom = "" - } - if let media = signal?["mediaMaxMb"]?.doubleValue ?? signal?["mediaMaxMb"]?.intValue.map(Double.init) { - self.signalMediaMaxMb = String(Int(media)) - } else { - self.signalMediaMaxMb = "" - } - - let imessage = snap.config?["imessage"]?.dictionaryValue - 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 ?? "" - if let allow = imessage?["allowFrom"]?.arrayValue { - let strings = allow.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 - } - self.imessageAllowFrom = strings.joined(separator: ", ") - } else { - self.imessageAllowFrom = "" - } - self.imessageIncludeAttachments = imessage?["includeAttachments"]?.boolValue ?? false - if let media = imessage?["mediaMaxMb"]?.doubleValue ?? imessage?["mediaMaxMb"]?.intValue.map(Double.init) { - self.imessageMediaMaxMb = String(Int(media)) - } else { - self.imessageMediaMaxMb = "" - } - } catch { - self.configStatus = error.localizedDescription - } - } - - 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] = (self.configRoot["telegram"] as? [String: Any]) ?? [:] - let token = self.telegramToken.trimmingCharacters(in: .whitespacesAndNewlines) - if token.isEmpty { - telegram.removeValue(forKey: "botToken") - } else { - telegram["botToken"] = token - } - - if self.telegramRequireMention { - telegram["requireMention"] = true - } else { - telegram["requireMention"] = false - } - - let allow = self.telegramAllowFrom - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - if allow.isEmpty { - telegram.removeValue(forKey: "allowFrom") - } else { - telegram["allowFrom"] = allow - } - - let proxy = self.telegramProxy.trimmingCharacters(in: .whitespacesAndNewlines) - if proxy.isEmpty { - telegram.removeValue(forKey: "proxy") - } else { - telegram["proxy"] = proxy - } - - let webhookUrl = self.telegramWebhookUrl.trimmingCharacters(in: .whitespacesAndNewlines) - if webhookUrl.isEmpty { - telegram.removeValue(forKey: "webhookUrl") - } else { - telegram["webhookUrl"] = webhookUrl - } - - let webhookSecret = self.telegramWebhookSecret.trimmingCharacters(in: .whitespacesAndNewlines) - if webhookSecret.isEmpty { - telegram.removeValue(forKey: "webhookSecret") - } else { - telegram["webhookSecret"] = webhookSecret - } - - let webhookPath = self.telegramWebhookPath.trimmingCharacters(in: .whitespacesAndNewlines) - if webhookPath.isEmpty { - telegram.removeValue(forKey: "webhookPath") - } else { - telegram["webhookPath"] = webhookPath - } - - if telegram.isEmpty { - self.configRoot.removeValue(forKey: "telegram") - } else { - self.configRoot["telegram"] = telegram - } - - do { - let data = try JSONSerialization.data( - withJSONObject: self.configRoot, - 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)] - _ = try await GatewayConnection.shared.requestRaw( - method: .configSet, - params: params, - timeoutMs: 10000) - self.configStatus = "Saved to ~/.clawdbot/clawdbot.json." - await self.refresh(probe: true) - } catch { - self.configStatus = error.localizedDescription - } - } - - func saveDiscordConfig() async { - guard !self.isSavingConfig else { return } - self.isSavingConfig = true - defer { self.isSavingConfig = false } - if !self.configLoaded { - await self.loadConfig() - } - - var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:] - if self.discordEnabled { - discord.removeValue(forKey: "enabled") - } else { - discord["enabled"] = false - } - let token = self.discordToken.trimmingCharacters(in: .whitespacesAndNewlines) - if token.isEmpty { - discord.removeValue(forKey: "token") - } else { - discord["token"] = token - } - - var dm: [String: Any] = (discord["dm"] as? [String: Any]) ?? [:] - if self.discordDmEnabled { - dm.removeValue(forKey: "enabled") - } else { - dm["enabled"] = false - } - let allow = self.discordAllowFrom - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - if allow.isEmpty { - dm.removeValue(forKey: "allowFrom") - } else { - dm["allowFrom"] = allow - } - - if self.discordGroupEnabled { - dm["groupEnabled"] = true - } else { - dm.removeValue(forKey: "groupEnabled") - } - - let groupChannels = self.discordGroupChannels - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - if groupChannels.isEmpty { - dm.removeValue(forKey: "groupChannels") - } else { - dm["groupChannels"] = groupChannels - } - - if dm.isEmpty { - discord.removeValue(forKey: "dm") - } else { - discord["dm"] = dm - } - - let media = self.discordMediaMaxMb.trimmingCharacters(in: .whitespacesAndNewlines) - if media.isEmpty { - discord.removeValue(forKey: "mediaMaxMb") - } else if let value = Double(media) { - discord["mediaMaxMb"] = value - } - - let history = self.discordHistoryLimit.trimmingCharacters(in: .whitespacesAndNewlines) - if history.isEmpty { - discord.removeValue(forKey: "historyLimit") - } else if let value = Int(history), value >= 0 { - discord["historyLimit"] = value - } else { - discord.removeValue(forKey: "historyLimit") - } - - let chunkLimit = self.discordTextChunkLimit.trimmingCharacters(in: .whitespacesAndNewlines) - if chunkLimit.isEmpty { - discord.removeValue(forKey: "textChunkLimit") - } else if let value = Int(chunkLimit), value > 0 { - discord["textChunkLimit"] = value - } else { - discord.removeValue(forKey: "textChunkLimit") - } - - let replyToMode = self.discordReplyToMode.trimmingCharacters(in: .whitespacesAndNewlines) - if replyToMode.isEmpty || replyToMode == "off" { - discord.removeValue(forKey: "replyToMode") - } else if ["first", "all"].contains(replyToMode) { - discord["replyToMode"] = replyToMode - } else { - discord.removeValue(forKey: "replyToMode") - } - - let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in - let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { return } - var payload: [String: Any] = [:] - let slug = entry.slug.trimmingCharacters(in: .whitespacesAndNewlines) - if !slug.isEmpty { payload["slug"] = slug } - if entry.requireMention { payload["requireMention"] = true } - if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) { - payload["reactionNotifications"] = entry.reactionNotifications - } - let users = entry.users - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - if !users.isEmpty { payload["users"] = users } - let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in - let channelKey = channel.key.trimmingCharacters(in: .whitespacesAndNewlines) - guard !channelKey.isEmpty else { return } - var channelPayload: [String: Any] = [:] - if !channel.allow { channelPayload["allow"] = false } - if channel.requireMention { channelPayload["requireMention"] = true } - channelsResult[channelKey] = channelPayload - } - if !channels.isEmpty { payload["channels"] = channels } - result[key] = payload - } - if guilds.isEmpty { - discord.removeValue(forKey: "guilds") - } else { - discord["guilds"] = guilds - } - - var actions: [String: Any] = (discord["actions"] as? [String: Any]) ?? [:] - func setAction(_ key: String, value: Bool, defaultValue: Bool) { - if value == defaultValue { - actions.removeValue(forKey: key) - } else { - actions[key] = value - } - } - setAction("reactions", value: self.discordActionReactions, defaultValue: true) - setAction("stickers", value: self.discordActionStickers, defaultValue: true) - setAction("polls", value: self.discordActionPolls, defaultValue: true) - setAction("permissions", value: self.discordActionPermissions, defaultValue: true) - setAction("messages", value: self.discordActionMessages, defaultValue: true) - setAction("threads", value: self.discordActionThreads, defaultValue: true) - setAction("pins", value: self.discordActionPins, defaultValue: true) - setAction("search", value: self.discordActionSearch, defaultValue: true) - setAction("memberInfo", value: self.discordActionMemberInfo, defaultValue: true) - setAction("roleInfo", value: self.discordActionRoleInfo, defaultValue: true) - setAction("channelInfo", value: self.discordActionChannelInfo, defaultValue: true) - setAction("voiceStatus", value: self.discordActionVoiceStatus, defaultValue: true) - setAction("events", value: self.discordActionEvents, defaultValue: true) - setAction("roles", value: self.discordActionRoles, defaultValue: false) - setAction("moderation", value: self.discordActionModeration, defaultValue: false) - if actions.isEmpty { - discord.removeValue(forKey: "actions") - } else { - discord["actions"] = actions - } - - var slash: [String: Any] = (discord["slashCommand"] as? [String: Any]) ?? [:] - if self.discordSlashEnabled { - slash["enabled"] = true - } else { - slash.removeValue(forKey: "enabled") - } - let slashName = self.discordSlashName.trimmingCharacters(in: .whitespacesAndNewlines) - if slashName.isEmpty { - slash.removeValue(forKey: "name") - } else { - slash["name"] = slashName - } - let slashPrefix = self.discordSlashSessionPrefix.trimmingCharacters(in: .whitespacesAndNewlines) - if slashPrefix.isEmpty { - slash.removeValue(forKey: "sessionPrefix") - } else { - slash["sessionPrefix"] = slashPrefix - } - if self.discordSlashEphemeral { - slash.removeValue(forKey: "ephemeral") - } else { - slash["ephemeral"] = false - } - if slash.isEmpty { - discord.removeValue(forKey: "slashCommand") - } else { - discord["slashCommand"] = slash - } - - if discord.isEmpty { - self.configRoot.removeValue(forKey: "discord") - } else { - self.configRoot["discord"] = discord - } - - do { - let data = try JSONSerialization.data( - withJSONObject: self.configRoot, - 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)] - _ = try await GatewayConnection.shared.requestRaw( - method: .configSet, - params: params, - timeoutMs: 10000) - self.configStatus = "Saved to ~/.clawdbot/clawdbot.json." - await self.refresh(probe: true) - } catch { - self.configStatus = error.localizedDescription - } - } - - 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.configRoot["signal"] as? [String: Any]) ?? [:] - if self.signalEnabled { - signal.removeValue(forKey: "enabled") - } else { - signal["enabled"] = false - } - - let account = self.signalAccount.trimmingCharacters(in: .whitespacesAndNewlines) - if account.isEmpty { - signal.removeValue(forKey: "account") - } else { - signal["account"] = account - } - - let httpUrl = self.signalHttpUrl.trimmingCharacters(in: .whitespacesAndNewlines) - if httpUrl.isEmpty { - signal.removeValue(forKey: "httpUrl") - } else { - signal["httpUrl"] = httpUrl - } - - let httpHost = self.signalHttpHost.trimmingCharacters(in: .whitespacesAndNewlines) - if httpHost.isEmpty { - signal.removeValue(forKey: "httpHost") - } else { - signal["httpHost"] = httpHost - } - - let httpPort = self.signalHttpPort.trimmingCharacters(in: .whitespacesAndNewlines) - if httpPort.isEmpty { - signal.removeValue(forKey: "httpPort") - } else if let value = Double(httpPort) { - signal["httpPort"] = value - } - - let cliPath = self.signalCliPath.trimmingCharacters(in: .whitespacesAndNewlines) - if cliPath.isEmpty { - signal.removeValue(forKey: "cliPath") - } else { - signal["cliPath"] = cliPath - } - - if self.signalAutoStart { - signal.removeValue(forKey: "autoStart") - } else { - signal["autoStart"] = false - } - - let receiveMode = self.signalReceiveMode.trimmingCharacters(in: .whitespacesAndNewlines) - if receiveMode.isEmpty { - signal.removeValue(forKey: "receiveMode") - } else { - signal["receiveMode"] = receiveMode - } - - if self.signalIgnoreAttachments { - signal["ignoreAttachments"] = true - } else { - signal.removeValue(forKey: "ignoreAttachments") - } - if self.signalIgnoreStories { - signal["ignoreStories"] = true - } else { - signal.removeValue(forKey: "ignoreStories") - } - if self.signalSendReadReceipts { - signal["sendReadReceipts"] = true - } else { - signal.removeValue(forKey: "sendReadReceipts") - } - - let allow = self.signalAllowFrom - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - if allow.isEmpty { - signal.removeValue(forKey: "allowFrom") - } else { - signal["allowFrom"] = allow - } - - let media = self.signalMediaMaxMb.trimmingCharacters(in: .whitespacesAndNewlines) - if media.isEmpty { - signal.removeValue(forKey: "mediaMaxMb") - } else if let value = Double(media) { - signal["mediaMaxMb"] = value - } - - if signal.isEmpty { - self.configRoot.removeValue(forKey: "signal") - } else { - self.configRoot["signal"] = signal - } - - do { - let data = try JSONSerialization.data( - withJSONObject: self.configRoot, - 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)] - _ = try await GatewayConnection.shared.requestRaw( - method: .configSet, - params: params, - timeoutMs: 10000) - self.configStatus = "Saved to ~/.clawdbot/clawdbot.json." - await self.refresh(probe: true) - } catch { - self.configStatus = error.localizedDescription - } - } - - 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.configRoot["imessage"] as? [String: Any]) ?? [:] - if self.imessageEnabled { - imessage.removeValue(forKey: "enabled") - } else { - imessage["enabled"] = false - } - - let cliPath = self.imessageCliPath.trimmingCharacters(in: .whitespacesAndNewlines) - if cliPath.isEmpty { - imessage.removeValue(forKey: "cliPath") - } else { - imessage["cliPath"] = cliPath - } - - let dbPath = self.imessageDbPath.trimmingCharacters(in: .whitespacesAndNewlines) - if dbPath.isEmpty { - imessage.removeValue(forKey: "dbPath") - } else { - imessage["dbPath"] = dbPath - } - - let service = self.imessageService.trimmingCharacters(in: .whitespacesAndNewlines) - if service.isEmpty || service == "auto" { - imessage.removeValue(forKey: "service") - } else { - imessage["service"] = service - } - - let region = self.imessageRegion.trimmingCharacters(in: .whitespacesAndNewlines) - if region.isEmpty { - imessage.removeValue(forKey: "region") - } else { - imessage["region"] = region - } - - let allow = self.imessageAllowFrom - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - if allow.isEmpty { - imessage.removeValue(forKey: "allowFrom") - } else { - imessage["allowFrom"] = allow - } - - if self.imessageIncludeAttachments { - imessage["includeAttachments"] = true - } else { - imessage.removeValue(forKey: "includeAttachments") - } - - let media = self.imessageMediaMaxMb.trimmingCharacters(in: .whitespacesAndNewlines) - if media.isEmpty { - imessage.removeValue(forKey: "mediaMaxMb") - } else if let value = Double(media) { - imessage["mediaMaxMb"] = value - } - - if imessage.isEmpty { - self.configRoot.removeValue(forKey: "imessage") - } else { - self.configRoot["imessage"] = imessage - } - - do { - let data = try JSONSerialization.data( - withJSONObject: self.configRoot, - 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)] - _ = try await GatewayConnection.shared.requestRaw( - method: .configSet, - params: params, - timeoutMs: 10000) - self.configStatus = "Saved to ~/.clawdbot/clawdbot.json." - await self.refresh(probe: true) - } catch { - self.configStatus = error.localizedDescription - } - } -} - -private struct WhatsAppLoginStartResult: Codable { - let qrDataUrl: String? - let message: String -} - -private struct WhatsAppLoginWaitResult: Codable { - let connected: Bool - let message: String -} - -private struct WhatsAppLogoutResult: Codable { - let cleared: Bool -} - -private struct TelegramLogoutResult: Codable { - let cleared: Bool - let envToken: Bool? }