From 5fbcbe7e52fdcd48780a0cefcdb2167b7d5708e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 26 Dec 2025 21:33:07 +0000 Subject: [PATCH] feat(mac): add discord connections UI --- .../Sources/Clawdis/ConnectionsSettings.swift | 152 +++++++++++++++- .../Sources/Clawdis/ConnectionsStore.swift | 166 ++++++++++++++++++ 2 files changed, 317 insertions(+), 1 deletion(-) diff --git a/apps/macos/Sources/Clawdis/ConnectionsSettings.swift b/apps/macos/Sources/Clawdis/ConnectionsSettings.swift index d99254618..bc355e86e 100644 --- a/apps/macos/Sources/Clawdis/ConnectionsSettings.swift +++ b/apps/macos/Sources/Clawdis/ConnectionsSettings.swift @@ -4,6 +4,7 @@ import SwiftUI struct ConnectionsSettings: View { @Bindable var store: ConnectionsStore @State private var showTelegramToken = false + @State private var showDiscordToken = false init(store: ConnectionsStore = .shared) { self.store = store @@ -15,6 +16,7 @@ struct ConnectionsSettings: View { self.header self.whatsAppSection self.telegramSection + self.discordSection Spacer(minLength: 0) } .frame(maxWidth: .infinity, alignment: .leading) @@ -29,7 +31,7 @@ struct ConnectionsSettings: View { VStack(alignment: .leading, spacing: 6) { Text("Connections") .font(.title3.weight(.semibold)) - Text("Link and monitor WhatsApp and Telegram providers.") + Text("Link and monitor WhatsApp, Telegram, and Discord providers.") .font(.callout) .foregroundStyle(.secondary) } @@ -216,6 +218,107 @@ struct ConnectionsSettings: View { } } + private var discordSection: some View { + GroupBox("Discord") { + VStack(alignment: .leading, spacing: 10) { + self.providerHeader( + title: "Discord Bot", + color: self.discordTint, + subtitle: self.discordSummary) + + if let details = self.discordDetails { + Text(details) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if let status = self.store.configStatus { + Text(status) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Divider().padding(.vertical, 2) + + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + 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) + } + GridRow { + self.gridLabel("Require mention") + Toggle("", isOn: self.$store.discordRequireMention) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Allow from") + TextField("discord:123, user:456", text: self.$store.discordAllowFrom) + .textFieldStyle(.roundedBorder) + } + GridRow { + self.gridLabel("Allow guilds") + TextField("guildId1, guildId2", text: self.$store.discordGuildAllowFrom) + .textFieldStyle(.roundedBorder) + } + GridRow { + self.gridLabel("Allow guild users") + TextField("userId1, userId2", text: self.$store.discordGuildUsersAllowFrom) + .textFieldStyle(.roundedBorder) + } + GridRow { + self.gridLabel("Media max MB") + TextField("8", text: self.$store.discordMediaMaxMb) + .textFieldStyle(.roundedBorder) + } + } + + if self.isDiscordTokenLocked { + Text("Token set via DISCORD_BOT_TOKEN env; config edits won’t override it.") + .font(.caption) + .foregroundStyle(.secondary) + } + + 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() + + Button("Refresh") { + Task { await self.store.refresh(probe: true) } + } + .buttonStyle(.bordered) + .disabled(self.store.isRefreshing) + } + .font(.caption) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + private var whatsAppTint: Color { guard let status = self.store.snapshot?.whatsapp else { return .secondary } if !status.linked { return .red } @@ -232,6 +335,14 @@ struct ConnectionsSettings: View { return .secondary } + private var discordTint: Color { + guard let status = self.store.snapshot?.discord else { return .secondary } + if !status.configured { return .secondary } + if status.running { return .green } + if status.lastError != nil { return .orange } + return .secondary + } + private var whatsAppSummary: String { guard let status = self.store.snapshot?.whatsapp else { return "Checking…" } if !status.linked { return "Not linked" } @@ -247,6 +358,13 @@ struct ConnectionsSettings: View { 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 whatsAppDetails: String? { guard let status = self.store.snapshot?.whatsapp else { return nil } var lines: [String] = [] @@ -308,10 +426,42 @@ struct ConnectionsSettings: View { 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 isTelegramTokenLocked: Bool { self.store.snapshot?.telegram.tokenSource == "env" } + private var isDiscordTokenLocked: Bool { + self.store.snapshot?.discord?.tokenSource == "env" + } + private func providerHeader(title: String, color: Color, subtitle: String) -> some View { HStack(spacing: 10) { Circle() diff --git a/apps/macos/Sources/Clawdis/ConnectionsStore.swift b/apps/macos/Sources/Clawdis/ConnectionsStore.swift index e57b98183..d89468a2b 100644 --- a/apps/macos/Sources/Clawdis/ConnectionsStore.swift +++ b/apps/macos/Sources/Clawdis/ConnectionsStore.swift @@ -61,9 +61,34 @@ struct ProvidersStatusSnapshot: Codable { let lastProbeAt: Double? } + struct DiscordBot: Codable { + let id: String? + let username: String? + } + + struct DiscordProbe: Codable { + let ok: Bool + let status: Int? + let error: String? + let elapsedMs: Double? + let bot: DiscordBot? + } + + struct DiscordStatus: Codable { + let configured: Bool + let tokenSource: String? + let running: Bool + let lastStartAt: Double? + let lastStopAt: Double? + let lastError: String? + let probe: DiscordProbe? + let lastProbeAt: Double? + } + let ts: Double let whatsapp: WhatsAppStatus let telegram: TelegramStatus + let discord: DiscordStatus? } struct ConfigSnapshot: Codable { @@ -104,6 +129,12 @@ final class ConnectionsStore { var telegramWebhookSecret: String = "" var telegramWebhookPath: String = "" var telegramBusy = false + var discordToken: String = "" + var discordRequireMention = true + var discordAllowFrom: String = "" + var discordGuildAllowFrom: String = "" + var discordGuildUsersAllowFrom: String = "" + var discordMediaMaxMb: String = "" var configStatus: String? var isSavingConfig = false @@ -281,6 +312,53 @@ final class ConnectionsStore { self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? "" self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? "" self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? "" + + let discord = snap.config?["discord"]?.dictionaryValue + self.discordToken = discord?["token"]?.stringValue ?? "" + self.discordRequireMention = discord?["requireMention"]?.boolValue ?? true + if let allow = discord?["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 = "" + } + if let guildAllow = discord?["guildAllowFrom"]?.dictionaryValue { + if let guilds = guildAllow["guilds"]?.arrayValue { + let strings = guilds.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.discordGuildAllowFrom = strings.joined(separator: ", ") + } else { + self.discordGuildAllowFrom = "" + } + if let users = guildAllow["users"]?.arrayValue { + let strings = users.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.discordGuildUsersAllowFrom = strings.joined(separator: ", ") + } else { + self.discordGuildUsersAllowFrom = "" + } + } else { + self.discordGuildAllowFrom = "" + self.discordGuildUsersAllowFrom = "" + } + if let media = discord?["mediaMaxMb"]?.doubleValue ?? discord?["mediaMaxMb"]?.intValue.map(Double.init) { + self.discordMediaMaxMb = String(Int(media)) + } else { + self.discordMediaMaxMb = "" + } } catch { self.configStatus = error.localizedDescription } @@ -371,6 +449,94 @@ final class ConnectionsStore { 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]) ?? [:] + let token = self.discordToken.trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + discord.removeValue(forKey: "token") + } else { + discord["token"] = token + } + + discord["requireMention"] = self.discordRequireMention + + let allow = self.discordAllowFrom + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + if allow.isEmpty { + discord.removeValue(forKey: "allowFrom") + } else { + discord["allowFrom"] = allow + } + + var guildAllow: [String: Any] = (discord["guildAllowFrom"] as? [String: Any]) ?? [:] + let guilds = self.discordGuildAllowFrom + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + if guilds.isEmpty { + guildAllow.removeValue(forKey: "guilds") + } else { + guildAllow["guilds"] = guilds + } + + let users = self.discordGuildUsersAllowFrom + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + if users.isEmpty { + guildAllow.removeValue(forKey: "users") + } else { + guildAllow["users"] = users + } + + if guildAllow.isEmpty { + discord.removeValue(forKey: "guildAllowFrom") + } else { + discord["guildAllowFrom"] = guildAllow + } + + let media = self.discordMediaMaxMb.trimmingCharacters(in: .whitespacesAndNewlines) + if media.isEmpty { + discord.removeValue(forKey: "mediaMaxMb") + } else if let value = Double(media) { + discord["mediaMaxMb"] = value + } + + 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 ~/.clawdis/clawdis.json." + await self.refresh(probe: true) + } catch { + self.configStatus = error.localizedDescription + } + } } private struct WhatsAppLoginStartResult: Codable {