feat(mac): add discord connections UI
This commit is contained in:
@@ -4,6 +4,7 @@ import SwiftUI
|
|||||||
struct ConnectionsSettings: View {
|
struct ConnectionsSettings: View {
|
||||||
@Bindable var store: ConnectionsStore
|
@Bindable var store: ConnectionsStore
|
||||||
@State private var showTelegramToken = false
|
@State private var showTelegramToken = false
|
||||||
|
@State private var showDiscordToken = false
|
||||||
|
|
||||||
init(store: ConnectionsStore = .shared) {
|
init(store: ConnectionsStore = .shared) {
|
||||||
self.store = store
|
self.store = store
|
||||||
@@ -15,6 +16,7 @@ struct ConnectionsSettings: View {
|
|||||||
self.header
|
self.header
|
||||||
self.whatsAppSection
|
self.whatsAppSection
|
||||||
self.telegramSection
|
self.telegramSection
|
||||||
|
self.discordSection
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -29,7 +31,7 @@ struct ConnectionsSettings: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Connections")
|
Text("Connections")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
Text("Link and monitor WhatsApp and Telegram providers.")
|
Text("Link and monitor WhatsApp, Telegram, and Discord providers.")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.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 {
|
private var whatsAppTint: Color {
|
||||||
guard let status = self.store.snapshot?.whatsapp else { return .secondary }
|
guard let status = self.store.snapshot?.whatsapp else { return .secondary }
|
||||||
if !status.linked { return .red }
|
if !status.linked { return .red }
|
||||||
@@ -232,6 +335,14 @@ struct ConnectionsSettings: View {
|
|||||||
return .secondary
|
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 {
|
private var whatsAppSummary: String {
|
||||||
guard let status = self.store.snapshot?.whatsapp else { return "Checking…" }
|
guard let status = self.store.snapshot?.whatsapp else { return "Checking…" }
|
||||||
if !status.linked { return "Not linked" }
|
if !status.linked { return "Not linked" }
|
||||||
@@ -247,6 +358,13 @@ struct ConnectionsSettings: View {
|
|||||||
return "Configured"
|
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? {
|
private var whatsAppDetails: String? {
|
||||||
guard let status = self.store.snapshot?.whatsapp else { return nil }
|
guard let status = self.store.snapshot?.whatsapp else { return nil }
|
||||||
var lines: [String] = []
|
var lines: [String] = []
|
||||||
@@ -308,10 +426,42 @@ struct ConnectionsSettings: View {
|
|||||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
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 {
|
private var isTelegramTokenLocked: Bool {
|
||||||
self.store.snapshot?.telegram.tokenSource == "env"
|
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 {
|
private func providerHeader(title: String, color: Color, subtitle: String) -> some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Circle()
|
Circle()
|
||||||
|
|||||||
@@ -61,9 +61,34 @@ struct ProvidersStatusSnapshot: Codable {
|
|||||||
let lastProbeAt: Double?
|
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 ts: Double
|
||||||
let whatsapp: WhatsAppStatus
|
let whatsapp: WhatsAppStatus
|
||||||
let telegram: TelegramStatus
|
let telegram: TelegramStatus
|
||||||
|
let discord: DiscordStatus?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ConfigSnapshot: Codable {
|
struct ConfigSnapshot: Codable {
|
||||||
@@ -104,6 +129,12 @@ final class ConnectionsStore {
|
|||||||
var telegramWebhookSecret: String = ""
|
var telegramWebhookSecret: String = ""
|
||||||
var telegramWebhookPath: String = ""
|
var telegramWebhookPath: String = ""
|
||||||
var telegramBusy = false
|
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 configStatus: String?
|
||||||
var isSavingConfig = false
|
var isSavingConfig = false
|
||||||
|
|
||||||
@@ -281,6 +312,53 @@ final class ConnectionsStore {
|
|||||||
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
|
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
|
||||||
self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? ""
|
self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? ""
|
||||||
self.telegramWebhookPath = telegram?["webhookPath"]?.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 {
|
} catch {
|
||||||
self.configStatus = error.localizedDescription
|
self.configStatus = error.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -371,6 +449,94 @@ final class ConnectionsStore {
|
|||||||
self.configStatus = error.localizedDescription
|
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 {
|
private struct WhatsAppLoginStartResult: Codable {
|
||||||
|
|||||||
Reference in New Issue
Block a user