From 729a5451735669d20c40bd3eae91cd77746e9acd Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 2 Jan 2026 12:06:05 -0600 Subject: [PATCH] Update connections UIs --- AGENTS.md | 1 + .../Sources/Clawdis/ConnectionsSettings.swift | 383 +++++- .../Sources/Clawdis/ConnectionsStore.swift | 330 +++++ .../ConnectionsSettingsSmokeTests.swift | 52 +- ui/src/ui/app-render.ts | 42 +- ui/src/ui/app.ts | 75 +- ui/src/ui/controllers/config.ts | 96 +- ui/src/ui/controllers/connections.ts | 274 +++- ui/src/ui/types.ts | 63 + ui/src/ui/ui-types.ts | 43 +- ui/src/ui/views/connections.ts | 1196 +++++++++++++---- ui/src/ui/views/overview.ts | 2 +- 12 files changed, 2298 insertions(+), 259 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1da355f35..f1ee1923a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ - macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`. - Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there. - SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code. +- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync. - **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch. - **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators. - iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`. diff --git a/apps/macos/Sources/Clawdis/ConnectionsSettings.swift b/apps/macos/Sources/Clawdis/ConnectionsSettings.swift index 52d4286c1..4ab3d0617 100644 --- a/apps/macos/Sources/Clawdis/ConnectionsSettings.swift +++ b/apps/macos/Sources/Clawdis/ConnectionsSettings.swift @@ -2,6 +2,26 @@ import AppKit import SwiftUI struct ConnectionsSettings: View { + private enum ConnectionProvider: String, CaseIterable, Identifiable { + case whatsapp + case telegram + case discord + case signal + case imessage + + var id: String { self.rawValue } + + var sortOrder: Int { + switch self { + case .whatsapp: return 0 + case .telegram: return 1 + case .discord: return 2 + case .signal: return 3 + case .imessage: return 4 + } + } + } + @Bindable var store: ConnectionsStore @State private var showTelegramToken = false @State private var showDiscordToken = false @@ -14,9 +34,9 @@ struct ConnectionsSettings: View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: 14) { self.header - self.whatsAppSection - self.telegramSection - self.discordSection + ForEach(self.orderedProviders) { provider in + self.providerSection(provider) + } Spacer(minLength: 0) } .frame(maxWidth: .infinity, alignment: .leading) @@ -31,7 +51,7 @@ struct ConnectionsSettings: View { VStack(alignment: .leading, spacing: 6) { Text("Connections") .font(.title3.weight(.semibold)) - Text("Link and monitor WhatsApp, Telegram, and Discord providers.") + Text("Link and monitor messaging providers.") .font(.callout) .foregroundStyle(.secondary) } @@ -319,6 +339,236 @@ struct ConnectionsSettings: View { } } + private var signalSection: some View { + GroupBox("Signal") { + VStack(alignment: .leading, spacing: 10) { + self.providerHeader( + title: "Signal REST", + color: self.signalTint, + subtitle: self.signalSummary) + + if let details = self.signalDetails { + 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("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) + } + 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) + } + 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) + } + } + + 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() + + Button("Refresh") { + Task { await self.store.refresh(probe: true) } + } + .buttonStyle(.bordered) + .disabled(self.store.isRefreshing) + } + .font(.caption) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var imessageSection: some View { + GroupBox("iMessage") { + VStack(alignment: .leading, spacing: 10) { + self.providerHeader( + title: "iMessage (imsg)", + color: self.imessageTint, + subtitle: self.imessageSummary) + + if let details = self.imessageDetails { + 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("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) + } + 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) + } + } + + 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() + + 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.configured { return .secondary } @@ -347,6 +597,24 @@ struct ConnectionsSettings: View { 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" } @@ -369,6 +637,20 @@ struct ConnectionsSettings: View { 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] = [] @@ -458,6 +740,54 @@ struct ConnectionsSettings: View { 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" } @@ -466,6 +796,51 @@ struct ConnectionsSettings: View { 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 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 + } + } + 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 0ff9f0bb5..cad06cde1 100644 --- a/apps/macos/Sources/Clawdis/ConnectionsStore.swift +++ b/apps/macos/Sources/Clawdis/ConnectionsStore.swift @@ -85,10 +85,48 @@ struct ProvidersStatusSnapshot: Codable { let lastProbeAt: Double? } + struct SignalProbe: Codable { + let ok: Bool + let status: Int? + let error: String? + let elapsedMs: Double? + let version: String? + } + + struct SignalStatus: Codable { + let configured: Bool + let baseUrl: String + let running: Bool + let lastStartAt: Double? + let lastStopAt: Double? + let lastError: String? + let probe: SignalProbe? + let lastProbeAt: Double? + } + + struct IMessageProbe: Codable { + let ok: Bool + let error: String? + } + + struct IMessageStatus: Codable { + let configured: Bool + let running: Bool + let lastStartAt: Double? + let lastStopAt: Double? + let lastError: String? + let cliPath: String? + let dbPath: String? + let probe: IMessageProbe? + let lastProbeAt: Double? + } + let ts: Double let whatsapp: WhatsAppStatus let telegram: TelegramStatus let discord: DiscordStatus? + let signal: SignalStatus? + let imessage: IMessageStatus? } struct ConfigSnapshot: Codable { @@ -135,6 +173,27 @@ final class ConnectionsStore { var discordGuildAllowFrom: String = "" var discordGuildUsersAllowFrom: String = "" var discordMediaMaxMb: String = "" + var signalEnabled = true + var signalAccount: String = "" + var signalHttpUrl: String = "" + var signalHttpHost: String = "" + var signalHttpPort: String = "" + var signalCliPath: String = "" + var signalAutoStart = true + var signalReceiveMode: String = "" + var signalIgnoreAttachments = false + var signalIgnoreStories = false + var signalSendReadReceipts = false + var signalAllowFrom: String = "" + var signalMediaMaxMb: String = "" + var imessageEnabled = true + var imessageCliPath: String = "" + var imessageDbPath: String = "" + var imessageService: String = "auto" + var imessageRegion: String = "" + var imessageAllowFrom: String = "" + var imessageIncludeAttachments = false + var imessageMediaMaxMb: String = "" var configStatus: String? var isSavingConfig = false @@ -364,6 +423,63 @@ final class ConnectionsStore { } else { self.discordMediaMaxMb = "" } + + 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 } @@ -542,6 +658,220 @@ final class ConnectionsStore { 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 ~/.clawdis/clawdis.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 ~/.clawdis/clawdis.json." + await self.refresh(probe: true) + } catch { + self.configStatus = error.localizedDescription + } + } } private struct WhatsAppLoginStartResult: Codable { diff --git a/apps/macos/Tests/ClawdisIPCTests/ConnectionsSettingsSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/ConnectionsSettingsSmokeTests.swift index a9ba93a5f..dd1151d2a 100644 --- a/apps/macos/Tests/ClawdisIPCTests/ConnectionsSettingsSmokeTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/ConnectionsSettingsSmokeTests.swift @@ -44,7 +44,31 @@ struct ConnectionsSettingsSmokeTests { bot: ProvidersStatusSnapshot.TelegramBot(id: 123, username: "clawdisbot"), webhook: ProvidersStatusSnapshot.TelegramWebhook(url: "https://example.com/hook", hasCustomCert: false)), lastProbeAt: 1_700_000_050_000), - discord: nil) + discord: nil, + signal: ProvidersStatusSnapshot.SignalStatus( + configured: true, + baseUrl: "http://127.0.0.1:8080", + running: true, + lastStartAt: 1_700_000_000_000, + lastStopAt: nil, + lastError: nil, + probe: ProvidersStatusSnapshot.SignalProbe( + ok: true, + status: 200, + error: nil, + elapsedMs: 140, + version: "0.12.4"), + lastProbeAt: 1_700_000_050_000), + imessage: ProvidersStatusSnapshot.IMessageStatus( + configured: false, + running: false, + lastStartAt: nil, + lastStopAt: nil, + lastError: "not configured", + cliPath: nil, + dbPath: nil, + probe: ProvidersStatusSnapshot.IMessageProbe(ok: false, error: "imsg not found (imsg)"), + lastProbeAt: 1_700_000_050_000)) store.whatsappLoginMessage = "Scan QR" store.whatsappLoginQrDataUrl = @@ -94,7 +118,31 @@ struct ConnectionsSettingsSmokeTests { bot: nil, webhook: nil), lastProbeAt: 1_700_000_100_000), - discord: nil) + discord: nil, + signal: ProvidersStatusSnapshot.SignalStatus( + configured: false, + baseUrl: "http://127.0.0.1:8080", + running: false, + lastStartAt: nil, + lastStopAt: nil, + lastError: "not configured", + probe: ProvidersStatusSnapshot.SignalProbe( + ok: false, + status: 404, + error: "unreachable", + elapsedMs: 200, + version: nil), + lastProbeAt: 1_700_000_200_000), + imessage: ProvidersStatusSnapshot.IMessageStatus( + configured: false, + running: false, + lastStartAt: nil, + lastStopAt: nil, + lastError: "not configured", + cliPath: "imsg", + dbPath: nil, + probe: ProvidersStatusSnapshot.IMessageProbe(ok: false, error: "imsg not found (imsg)"), + lastProbeAt: 1_700_000_200_000)) let view = ConnectionsSettings(store: store) _ = view.body diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 9fa1afcd6..a265f5f90 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -23,7 +23,13 @@ import type { SkillStatusReport, StatusSummary, } from "./types"; -import type { CronFormState, TelegramForm } from "./ui-types"; +import type { + CronFormState, + DiscordForm, + IMessageForm, + SignalForm, + TelegramForm, +} from "./ui-types"; import { renderChat } from "./views/chat"; import { renderConfig } from "./views/config"; import { renderConnections } from "./views/connections"; @@ -34,7 +40,13 @@ import { renderNodes } from "./views/nodes"; import { renderOverview } from "./views/overview"; import { renderSessions } from "./views/sessions"; import { renderSkills } from "./views/skills"; -import { loadProviders } from "./controllers/connections"; +import { + loadProviders, + updateDiscordForm, + updateIMessageForm, + updateSignalForm, + updateTelegramForm, +} from "./controllers/connections"; import { loadPresence } from "./controllers/presence"; import { loadSessions, patchSession } from "./controllers/sessions"; import { @@ -95,6 +107,16 @@ export type AppViewState = { telegramSaving: boolean; telegramTokenLocked: boolean; telegramConfigStatus: string | null; + discordForm: DiscordForm; + discordSaving: boolean; + discordTokenLocked: boolean; + discordConfigStatus: string | null; + signalForm: SignalForm; + signalSaving: boolean; + signalConfigStatus: string | null; + imessageForm: IMessageForm; + imessageSaving: boolean; + imessageConfigStatus: string | null; presenceLoading: boolean; presenceEntries: PresenceEntry[]; presenceError: string | null; @@ -235,12 +257,28 @@ export function renderApp(state: AppViewState) { telegramTokenLocked: state.telegramTokenLocked, telegramSaving: state.telegramSaving, telegramStatus: state.telegramConfigStatus, + discordForm: state.discordForm, + discordTokenLocked: state.discordTokenLocked, + discordSaving: state.discordSaving, + discordStatus: state.discordConfigStatus, + signalForm: state.signalForm, + signalSaving: state.signalSaving, + signalStatus: state.signalConfigStatus, + imessageForm: state.imessageForm, + imessageSaving: state.imessageSaving, + imessageStatus: state.imessageConfigStatus, onRefresh: (probe) => loadProviders(state, probe), onWhatsAppStart: (force) => state.handleWhatsAppStart(force), onWhatsAppWait: () => state.handleWhatsAppWait(), onWhatsAppLogout: () => state.handleWhatsAppLogout(), onTelegramChange: (patch) => updateTelegramForm(state, patch), onTelegramSave: () => state.handleTelegramSave(), + onDiscordChange: (patch) => updateDiscordForm(state, patch), + onDiscordSave: () => state.handleDiscordSave(), + onSignalChange: (patch) => updateSignalForm(state, patch), + onSignalSave: () => state.handleSignalSave(), + onIMessageChange: (patch) => updateIMessageForm(state, patch), + onIMessageSave: () => state.handleIMessageSave(), }) : nothing} diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 3c142e8e8..721eda703 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -26,13 +26,22 @@ import type { SkillStatusReport, StatusSummary, } from "./types"; -import type { CronFormState, TelegramForm } from "./ui-types"; +import type { + CronFormState, + DiscordForm, + IMessageForm, + SignalForm, + TelegramForm, +} from "./ui-types"; import { loadChatHistory, sendChat, handleChatEvent } from "./controllers/chat"; import { loadNodes } from "./controllers/nodes"; import { loadConfig } from "./controllers/config"; import { loadProviders, logoutWhatsApp, + saveDiscordConfig, + saveIMessageConfig, + saveSignalConfig, saveTelegramConfig, startWhatsAppLogin, waitWhatsAppLogin, @@ -126,6 +135,52 @@ export class ClawdisApp extends LitElement { @state() telegramSaving = false; @state() telegramTokenLocked = false; @state() telegramConfigStatus: string | null = null; + @state() discordForm: DiscordForm = { + enabled: true, + token: "", + allowFrom: "", + groupEnabled: false, + groupChannels: "", + mediaMaxMb: "", + historyLimit: "", + enableReactions: true, + slashEnabled: false, + slashName: "", + slashSessionPrefix: "", + slashEphemeral: true, + }; + @state() discordSaving = false; + @state() discordTokenLocked = false; + @state() discordConfigStatus: string | null = null; + @state() signalForm: SignalForm = { + enabled: true, + account: "", + httpUrl: "", + httpHost: "", + httpPort: "", + cliPath: "", + autoStart: true, + receiveMode: "", + ignoreAttachments: false, + ignoreStories: false, + sendReadReceipts: false, + allowFrom: "", + mediaMaxMb: "", + }; + @state() signalSaving = false; + @state() signalConfigStatus: string | null = null; + @state() imessageForm: IMessageForm = { + enabled: true, + cliPath: "", + dbPath: "", + service: "auto", + region: "", + allowFrom: "", + includeAttachments: false, + mediaMaxMb: "", + }; + @state() imessageSaving = false; + @state() imessageConfigStatus: string | null = null; @state() presenceLoading = false; @state() presenceEntries: PresenceEntry[] = []; @@ -509,6 +564,24 @@ export class ClawdisApp extends LitElement { await loadProviders(this, true); } + async handleDiscordSave() { + await saveDiscordConfig(this); + await loadConfig(this); + await loadProviders(this, true); + } + + async handleSignalSave() { + await saveSignalConfig(this); + await loadConfig(this); + await loadProviders(this, true); + } + + async handleIMessageSave() { + await saveIMessageConfig(this); + await loadConfig(this); + await loadProviders(this, true); + } + render() { return renderApp(this); } diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index fe99633e1..3c2b78acb 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -1,6 +1,6 @@ import type { GatewayBrowserClient } from "../gateway"; import type { ConfigSnapshot } from "../types"; -import type { TelegramForm } from "../ui-types"; +import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types"; export type ConfigState = { client: GatewayBrowserClient | null; @@ -13,7 +13,13 @@ export type ConfigState = { configSnapshot: ConfigSnapshot | null; lastError: string | null; telegramForm: TelegramForm; + discordForm: DiscordForm; + signalForm: SignalForm; + imessageForm: IMessageForm; telegramConfigStatus: string | null; + discordConfigStatus: string | null; + signalConfigStatus: string | null; + imessageConfigStatus: string | null; }; export async function loadConfig(state: ConfigState) { @@ -42,11 +48,18 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot const config = snapshot.config ?? {}; const telegram = (config.telegram ?? {}) as Record; + const discord = (config.discord ?? {}) as Record; + const signal = (config.signal ?? {}) as Record; + const imessage = (config.imessage ?? {}) as Record; + const toList = (value: unknown) => + Array.isArray(value) + ? value + .map((v) => String(v ?? "").trim()) + .filter((v) => v.length > 0) + .join(", ") + : ""; const allowFrom = Array.isArray(telegram.allowFrom) - ? (telegram.allowFrom as unknown[]) - .map((v) => String(v ?? "").trim()) - .filter((v) => v.length > 0) - .join(", ") + ? toList(telegram.allowFrom) : typeof telegram.allowFrom === "string" ? telegram.allowFrom : ""; @@ -63,7 +76,77 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot webhookPath: typeof telegram.webhookPath === "string" ? telegram.webhookPath : "", }; - state.telegramConfigStatus = snapshot.valid === false ? "Config invalid." : null; + const discordDm = (discord.dm ?? {}) as Record; + const slash = (discord.slashCommand ?? {}) as Record; + state.discordForm = { + enabled: typeof discord.enabled === "boolean" ? discord.enabled : true, + token: typeof discord.token === "string" ? discord.token : "", + allowFrom: toList(discordDm.allowFrom), + groupEnabled: + typeof discordDm.groupEnabled === "boolean" ? discordDm.groupEnabled : false, + groupChannels: toList(discordDm.groupChannels), + mediaMaxMb: + typeof discord.mediaMaxMb === "number" ? String(discord.mediaMaxMb) : "", + historyLimit: + typeof discord.historyLimit === "number" ? String(discord.historyLimit) : "", + enableReactions: + typeof discord.enableReactions === "boolean" ? discord.enableReactions : true, + slashEnabled: typeof slash.enabled === "boolean" ? slash.enabled : false, + slashName: typeof slash.name === "string" ? slash.name : "", + slashSessionPrefix: + typeof slash.sessionPrefix === "string" ? slash.sessionPrefix : "", + slashEphemeral: + typeof slash.ephemeral === "boolean" ? slash.ephemeral : true, + }; + + state.signalForm = { + enabled: typeof signal.enabled === "boolean" ? signal.enabled : true, + account: typeof signal.account === "string" ? signal.account : "", + httpUrl: typeof signal.httpUrl === "string" ? signal.httpUrl : "", + httpHost: typeof signal.httpHost === "string" ? signal.httpHost : "", + httpPort: typeof signal.httpPort === "number" ? String(signal.httpPort) : "", + cliPath: typeof signal.cliPath === "string" ? signal.cliPath : "", + autoStart: typeof signal.autoStart === "boolean" ? signal.autoStart : true, + receiveMode: + signal.receiveMode === "on-start" || signal.receiveMode === "manual" + ? signal.receiveMode + : "", + ignoreAttachments: + typeof signal.ignoreAttachments === "boolean" ? signal.ignoreAttachments : false, + ignoreStories: + typeof signal.ignoreStories === "boolean" ? signal.ignoreStories : false, + sendReadReceipts: + typeof signal.sendReadReceipts === "boolean" ? signal.sendReadReceipts : false, + allowFrom: toList(signal.allowFrom), + mediaMaxMb: + typeof signal.mediaMaxMb === "number" ? String(signal.mediaMaxMb) : "", + }; + + state.imessageForm = { + enabled: typeof imessage.enabled === "boolean" ? imessage.enabled : true, + cliPath: typeof imessage.cliPath === "string" ? imessage.cliPath : "", + dbPath: typeof imessage.dbPath === "string" ? imessage.dbPath : "", + service: + imessage.service === "imessage" || + imessage.service === "sms" || + imessage.service === "auto" + ? imessage.service + : "auto", + region: typeof imessage.region === "string" ? imessage.region : "", + allowFrom: toList(imessage.allowFrom), + includeAttachments: + typeof imessage.includeAttachments === "boolean" + ? imessage.includeAttachments + : false, + mediaMaxMb: + typeof imessage.mediaMaxMb === "number" ? String(imessage.mediaMaxMb) : "", + }; + + const configInvalid = snapshot.valid === false ? "Config invalid." : null; + state.telegramConfigStatus = configInvalid; + state.discordConfigStatus = configInvalid; + state.signalConfigStatus = configInvalid; + state.imessageConfigStatus = configInvalid; } export async function saveConfig(state: ConfigState) { @@ -79,4 +162,3 @@ export async function saveConfig(state: ConfigState) { state.configSaving = false; } } - diff --git a/ui/src/ui/controllers/connections.ts b/ui/src/ui/controllers/connections.ts index ca3a3fd73..dd40617c5 100644 --- a/ui/src/ui/controllers/connections.ts +++ b/ui/src/ui/controllers/connections.ts @@ -1,7 +1,7 @@ import type { GatewayBrowserClient } from "../gateway"; import { parseList } from "../format"; import type { ConfigSnapshot, ProvidersStatusSnapshot } from "../types"; -import type { TelegramForm } from "../ui-types"; +import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types"; export type ConnectionsState = { client: GatewayBrowserClient | null; @@ -18,6 +18,16 @@ export type ConnectionsState = { telegramSaving: boolean; telegramTokenLocked: boolean; telegramConfigStatus: string | null; + discordForm: DiscordForm; + discordSaving: boolean; + discordTokenLocked: boolean; + discordConfigStatus: string | null; + signalForm: SignalForm; + signalSaving: boolean; + signalConfigStatus: string | null; + imessageForm: IMessageForm; + imessageSaving: boolean; + imessageConfigStatus: string | null; configSnapshot: ConfigSnapshot | null; }; @@ -34,6 +44,7 @@ export async function loadProviders(state: ConnectionsState, probe: boolean) { state.providersSnapshot = res; state.providersLastSuccess = Date.now(); state.telegramTokenLocked = res.telegram.tokenSource === "env"; + state.discordTokenLocked = res.discord?.tokenSource === "env"; } catch (err) { state.providersError = String(err); } finally { @@ -101,6 +112,27 @@ export function updateTelegramForm( state.telegramForm = { ...state.telegramForm, ...patch }; } +export function updateDiscordForm( + state: ConnectionsState, + patch: Partial, +) { + state.discordForm = { ...state.discordForm, ...patch }; +} + +export function updateSignalForm( + state: ConnectionsState, + patch: Partial, +) { + state.signalForm = { ...state.signalForm, ...patch }; +} + +export function updateIMessageForm( + state: ConnectionsState, + patch: Partial, +) { + state.imessageForm = { ...state.imessageForm, ...patch }; +} + export async function saveTelegramConfig(state: ConnectionsState) { if (!state.client || !state.connected) return; if (state.telegramSaving) return; @@ -143,3 +175,243 @@ export async function saveTelegramConfig(state: ConnectionsState) { } } +export async function saveDiscordConfig(state: ConnectionsState) { + if (!state.client || !state.connected) return; + if (state.discordSaving) return; + state.discordSaving = true; + state.discordConfigStatus = null; + try { + const base = state.configSnapshot?.config ?? {}; + const config = { ...base } as Record; + const discord = { ...(config.discord ?? {}) } as Record; + const form = state.discordForm; + + if (form.enabled) { + delete discord.enabled; + } else { + discord.enabled = false; + } + + if (!state.discordTokenLocked) { + const token = form.token.trim(); + if (token) discord.token = token; + else delete discord.token; + } + + const allowFrom = parseList(form.allowFrom); + const groupChannels = parseList(form.groupChannels); + const dm = { ...(discord.dm ?? {}) } as Record; + if (allowFrom.length > 0) dm.allowFrom = allowFrom; + else delete dm.allowFrom; + if (form.groupEnabled) dm.groupEnabled = true; + else delete dm.groupEnabled; + if (groupChannels.length > 0) dm.groupChannels = groupChannels; + else delete dm.groupChannels; + if (Object.keys(dm).length > 0) discord.dm = dm; + else delete discord.dm; + + const mediaMaxMb = Number(form.mediaMaxMb); + if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { + discord.mediaMaxMb = mediaMaxMb; + } else { + delete discord.mediaMaxMb; + } + + const historyLimit = Number(form.historyLimit); + if (Number.isFinite(historyLimit) && historyLimit >= 0) { + discord.historyLimit = historyLimit; + } else { + delete discord.historyLimit; + } + + if (form.enableReactions) { + delete discord.enableReactions; + } else { + discord.enableReactions = false; + } + + const slash = { ...(discord.slashCommand ?? {}) } as Record; + if (form.slashEnabled) { + slash.enabled = true; + } else { + delete slash.enabled; + } + if (form.slashName.trim()) slash.name = form.slashName.trim(); + else delete slash.name; + if (form.slashSessionPrefix.trim()) + slash.sessionPrefix = form.slashSessionPrefix.trim(); + else delete slash.sessionPrefix; + if (form.slashEphemeral) { + delete slash.ephemeral; + } else { + slash.ephemeral = false; + } + if (Object.keys(slash).length > 0) discord.slashCommand = slash; + else delete discord.slashCommand; + + if (Object.keys(discord).length > 0) { + config.discord = discord; + } else { + delete config.discord; + } + + const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; + await state.client.request("config.set", { raw }); + state.discordConfigStatus = "Saved. Restart gateway if needed."; + } catch (err) { + state.discordConfigStatus = String(err); + } finally { + state.discordSaving = false; + } +} + +export async function saveSignalConfig(state: ConnectionsState) { + if (!state.client || !state.connected) return; + if (state.signalSaving) return; + state.signalSaving = true; + state.signalConfigStatus = null; + try { + const base = state.configSnapshot?.config ?? {}; + const config = { ...base } as Record; + const signal = { ...(config.signal ?? {}) } as Record; + const form = state.signalForm; + + if (form.enabled) { + delete signal.enabled; + } else { + signal.enabled = false; + } + + const account = form.account.trim(); + if (account) signal.account = account; + else delete signal.account; + + const httpUrl = form.httpUrl.trim(); + if (httpUrl) signal.httpUrl = httpUrl; + else delete signal.httpUrl; + + const httpHost = form.httpHost.trim(); + if (httpHost) signal.httpHost = httpHost; + else delete signal.httpHost; + + const httpPort = Number(form.httpPort); + if (Number.isFinite(httpPort) && httpPort > 0) { + signal.httpPort = httpPort; + } else { + delete signal.httpPort; + } + + const cliPath = form.cliPath.trim(); + if (cliPath) signal.cliPath = cliPath; + else delete signal.cliPath; + + if (form.autoStart) { + delete signal.autoStart; + } else { + signal.autoStart = false; + } + + if (form.receiveMode === "on-start" || form.receiveMode === "manual") { + signal.receiveMode = form.receiveMode; + } else { + delete signal.receiveMode; + } + + if (form.ignoreAttachments) signal.ignoreAttachments = true; + else delete signal.ignoreAttachments; + if (form.ignoreStories) signal.ignoreStories = true; + else delete signal.ignoreStories; + if (form.sendReadReceipts) signal.sendReadReceipts = true; + else delete signal.sendReadReceipts; + + const allowFrom = parseList(form.allowFrom); + if (allowFrom.length > 0) signal.allowFrom = allowFrom; + else delete signal.allowFrom; + + const mediaMaxMb = Number(form.mediaMaxMb); + if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { + signal.mediaMaxMb = mediaMaxMb; + } else { + delete signal.mediaMaxMb; + } + + if (Object.keys(signal).length > 0) { + config.signal = signal; + } else { + delete config.signal; + } + + const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; + await state.client.request("config.set", { raw }); + state.signalConfigStatus = "Saved. Restart gateway if needed."; + } catch (err) { + state.signalConfigStatus = String(err); + } finally { + state.signalSaving = false; + } +} + +export async function saveIMessageConfig(state: ConnectionsState) { + if (!state.client || !state.connected) return; + if (state.imessageSaving) return; + state.imessageSaving = true; + state.imessageConfigStatus = null; + try { + const base = state.configSnapshot?.config ?? {}; + const config = { ...base } as Record; + const imessage = { ...(config.imessage ?? {}) } as Record; + const form = state.imessageForm; + + if (form.enabled) { + delete imessage.enabled; + } else { + imessage.enabled = false; + } + + const cliPath = form.cliPath.trim(); + if (cliPath) imessage.cliPath = cliPath; + else delete imessage.cliPath; + + const dbPath = form.dbPath.trim(); + if (dbPath) imessage.dbPath = dbPath; + else delete imessage.dbPath; + + if (form.service === "auto") { + delete imessage.service; + } else { + imessage.service = form.service; + } + + const region = form.region.trim(); + if (region) imessage.region = region; + else delete imessage.region; + + const allowFrom = parseList(form.allowFrom); + if (allowFrom.length > 0) imessage.allowFrom = allowFrom; + else delete imessage.allowFrom; + + if (form.includeAttachments) imessage.includeAttachments = true; + else delete imessage.includeAttachments; + + const mediaMaxMb = Number(form.mediaMaxMb); + if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { + imessage.mediaMaxMb = mediaMaxMb; + } else { + delete imessage.mediaMaxMb; + } + + if (Object.keys(imessage).length > 0) { + config.imessage = imessage; + } else { + delete config.imessage; + } + + const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; + await state.client.request("config.set", { raw }); + state.imessageConfigStatus = "Saved. Restart gateway if needed."; + } catch (err) { + state.imessageConfigStatus = String(err); + } finally { + state.imessageSaving = false; + } +} diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 1d2c36b5d..dd7fac7f2 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -2,6 +2,9 @@ export type ProvidersStatusSnapshot = { ts: number; whatsapp: WhatsAppStatus; telegram: TelegramStatus; + discord?: DiscordStatus | null; + signal?: SignalStatus | null; + imessage?: IMessageStatus | null; }; export type WhatsAppSelf = { @@ -62,6 +65,66 @@ export type TelegramStatus = { lastProbeAt?: number | null; }; +export type DiscordBot = { + id?: string | null; + username?: string | null; +}; + +export type DiscordProbe = { + ok: boolean; + status?: number | null; + error?: string | null; + elapsedMs?: number | null; + bot?: DiscordBot | null; +}; + +export type DiscordStatus = { + configured: boolean; + tokenSource?: string | null; + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + probe?: DiscordProbe | null; + lastProbeAt?: number | null; +}; + +export type SignalProbe = { + ok: boolean; + status?: number | null; + error?: string | null; + elapsedMs?: number | null; + version?: string | null; +}; + +export type SignalStatus = { + configured: boolean; + baseUrl: string; + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + probe?: SignalProbe | null; + lastProbeAt?: number | null; +}; + +export type IMessageProbe = { + ok: boolean; + error?: string | null; +}; + +export type IMessageStatus = { + configured: boolean; + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + cliPath?: string | null; + dbPath?: string | null; + probe?: IMessageProbe | null; + lastProbeAt?: number | null; +}; + export type ConfigSnapshotIssue = { path: string; message: string; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index a640520a8..ca83a8934 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -8,6 +8,48 @@ export type TelegramForm = { webhookPath: string; }; +export type DiscordForm = { + enabled: boolean; + token: string; + allowFrom: string; + groupEnabled: boolean; + groupChannels: string; + mediaMaxMb: string; + historyLimit: string; + enableReactions: boolean; + slashEnabled: boolean; + slashName: string; + slashSessionPrefix: string; + slashEphemeral: boolean; +}; + +export type SignalForm = { + enabled: boolean; + account: string; + httpUrl: string; + httpHost: string; + httpPort: string; + cliPath: string; + autoStart: boolean; + receiveMode: "on-start" | "manual" | ""; + ignoreAttachments: boolean; + ignoreStories: boolean; + sendReadReceipts: boolean; + allowFrom: string; + mediaMaxMb: string; +}; + +export type IMessageForm = { + enabled: boolean; + cliPath: string; + dbPath: string; + service: "auto" | "imessage" | "sms"; + region: string; + allowFrom: string; + includeAttachments: boolean; + mediaMaxMb: string; +}; + export type CronFormState = { name: string; description: string; @@ -28,4 +70,3 @@ export type CronFormState = { timeoutSeconds: string; postToMainPrefix: string; }; - diff --git a/ui/src/ui/views/connections.ts b/ui/src/ui/views/connections.ts index cdd415dc6..64d176e72 100644 --- a/ui/src/ui/views/connections.ts +++ b/ui/src/ui/views/connections.ts @@ -2,7 +2,7 @@ import { html, nothing } from "lit"; import { formatAgo } from "../format"; import type { ProvidersStatusSnapshot } from "../types"; -import type { TelegramForm } from "../ui-types"; +import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types"; export type ConnectionsProps = { connected: boolean; @@ -18,259 +18,59 @@ export type ConnectionsProps = { telegramTokenLocked: boolean; telegramSaving: boolean; telegramStatus: string | null; + discordForm: DiscordForm; + discordTokenLocked: boolean; + discordSaving: boolean; + discordStatus: string | null; + signalForm: SignalForm; + signalSaving: boolean; + signalStatus: string | null; + imessageForm: IMessageForm; + imessageSaving: boolean; + imessageStatus: string | null; onRefresh: (probe: boolean) => void; onWhatsAppStart: (force: boolean) => void; onWhatsAppWait: () => void; onWhatsAppLogout: () => void; onTelegramChange: (patch: Partial) => void; onTelegramSave: () => void; + onDiscordChange: (patch: Partial) => void; + onDiscordSave: () => void; + onSignalChange: (patch: Partial) => void; + onSignalSave: () => void; + onIMessageChange: (patch: Partial) => void; + onIMessageSave: () => void; }; export function renderConnections(props: ConnectionsProps) { const whatsapp = props.snapshot?.whatsapp; const telegram = props.snapshot?.telegram; + const discord = props.snapshot?.discord ?? null; + const signal = props.snapshot?.signal ?? null; + const imessage = props.snapshot?.imessage ?? null; + const providerOrder: ProviderKey[] = [ + "whatsapp", + "telegram", + "discord", + "signal", + "imessage", + ]; + const orderedProviders = providerOrder + .map((key, index) => ({ + key, + enabled: providerEnabled(key, props), + order: index, + })) + .sort((a, b) => { + if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; + return a.order - b.order; + }); return html`
-
-
WhatsApp
-
Link WhatsApp Web and monitor connection health.
- -
-
- Configured - ${whatsapp?.configured ? "Yes" : "No"} -
-
- Linked - ${whatsapp?.linked ? "Yes" : "No"} -
-
- Running - ${whatsapp?.running ? "Yes" : "No"} -
-
- Connected - ${whatsapp?.connected ? "Yes" : "No"} -
-
- Last connect - ${whatsapp?.lastConnectedAt ? formatAgo(whatsapp.lastConnectedAt) : "n/a"} -
-
- Last message - ${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"} -
-
- Auth age - - ${whatsapp?.authAgeMs != null ? formatDuration(whatsapp.authAgeMs) : "n/a"} - -
-
- - ${whatsapp?.lastError - ? html`
- ${whatsapp.lastError} -
` - : nothing} - - ${props.whatsappMessage - ? html`
- ${props.whatsappMessage} -
` - : nothing} - - ${props.whatsappQrDataUrl - ? html`
- WhatsApp QR -
` - : nothing} - -
- - - - - -
-
- -
-
Telegram
-
Bot token and delivery options.
- -
-
- Configured - ${telegram?.configured ? "Yes" : "No"} -
-
- Running - ${telegram?.running ? "Yes" : "No"} -
-
- Mode - ${telegram?.mode ?? "n/a"} -
-
- Last start - ${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"} -
-
- Last probe - ${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"} -
-
- - ${telegram?.lastError - ? html`
- ${telegram.lastError} -
` - : nothing} - - ${telegram?.probe - ? html`
- Probe ${telegram.probe.ok ? "ok" : "failed"} · - ${telegram.probe.status ?? ""} - ${telegram.probe.error ?? ""} -
` - : nothing} - -
- - - - - - - -
- - ${props.telegramTokenLocked - ? html`
- TELEGRAM_BOT_TOKEN is set in the environment. Config edits will not override it. -
` - : nothing} - - ${props.telegramStatus - ? html`
- ${props.telegramStatus} -
` - : nothing} - -
- - -
-
+ ${orderedProviders.map((provider) => + renderProvider(provider.key, props, { whatsapp, telegram, discord, signal, imessage }), + )}
@@ -302,3 +102,919 @@ function formatDuration(ms?: number | null) { const hr = Math.round(min / 60); return `${hr}h`; } + +type ProviderKey = "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; + +function providerEnabled(key: ProviderKey, props: ConnectionsProps) { + const snapshot = props.snapshot; + if (!snapshot) return false; + switch (key) { + case "whatsapp": + return ( + snapshot.whatsapp.configured || + snapshot.whatsapp.linked || + snapshot.whatsapp.running + ); + case "telegram": + return snapshot.telegram.configured || snapshot.telegram.running; + case "discord": + return Boolean(snapshot.discord?.configured || snapshot.discord?.running); + case "signal": + return Boolean(snapshot.signal?.configured || snapshot.signal?.running); + case "imessage": + return Boolean(snapshot.imessage?.configured || snapshot.imessage?.running); + default: + return false; + } +} + +function renderProvider( + key: ProviderKey, + props: ConnectionsProps, + data: { + whatsapp?: ProvidersStatusSnapshot["whatsapp"]; + telegram?: ProvidersStatusSnapshot["telegram"]; + discord?: ProvidersStatusSnapshot["discord"] | null; + signal?: ProvidersStatusSnapshot["signal"] | null; + imessage?: ProvidersStatusSnapshot["imessage"] | null; + }, +) { + switch (key) { + case "whatsapp": { + const whatsapp = data.whatsapp; + return html` +
+
WhatsApp
+
Link WhatsApp Web and monitor connection health.
+ +
+
+ Configured + ${whatsapp?.configured ? "Yes" : "No"} +
+
+ Linked + ${whatsapp?.linked ? "Yes" : "No"} +
+
+ Running + ${whatsapp?.running ? "Yes" : "No"} +
+
+ Connected + ${whatsapp?.connected ? "Yes" : "No"} +
+
+ Last connect + + ${whatsapp?.lastConnectedAt + ? formatAgo(whatsapp.lastConnectedAt) + : "n/a"} + +
+
+ Last message + + ${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"} + +
+
+ Auth age + + ${whatsapp?.authAgeMs != null ? formatDuration(whatsapp.authAgeMs) : "n/a"} + +
+
+ + ${whatsapp?.lastError + ? html`
+ ${whatsapp.lastError} +
` + : nothing} + + ${props.whatsappMessage + ? html`
+ ${props.whatsappMessage} +
` + : nothing} + + ${props.whatsappQrDataUrl + ? html`
+ WhatsApp QR +
` + : nothing} + +
+ + + + + +
+
+ `; + } + case "telegram": { + const telegram = data.telegram; + return html` +
+
Telegram
+
Bot token and delivery options.
+ +
+
+ Configured + ${telegram?.configured ? "Yes" : "No"} +
+
+ Running + ${telegram?.running ? "Yes" : "No"} +
+
+ Mode + ${telegram?.mode ?? "n/a"} +
+
+ Last start + ${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"} +
+
+ Last probe + ${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"} +
+
+ + ${telegram?.lastError + ? html`
+ ${telegram.lastError} +
` + : nothing} + + ${telegram?.probe + ? html`
+ Probe ${telegram.probe.ok ? "ok" : "failed"} · + ${telegram.probe.status ?? ""} + ${telegram.probe.error ?? ""} +
` + : nothing} + +
+ + + + + + + +
+ + ${props.telegramTokenLocked + ? html`
+ TELEGRAM_BOT_TOKEN is set in the environment. Config edits will not override it. +
` + : nothing} + + ${props.telegramStatus + ? html`
+ ${props.telegramStatus} +
` + : nothing} + +
+ + +
+
+ `; + } + case "discord": { + const discord = data.discord; + const botName = discord?.probe?.bot?.username; + return html` +
+
Discord
+
Bot connection and probe status.
+ +
+
+ Configured + ${discord?.configured ? "Yes" : "No"} +
+
+ Running + ${discord?.running ? "Yes" : "No"} +
+
+ Bot + ${botName ? `@${botName}` : "n/a"} +
+
+ Last start + ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"} +
+
+ Last probe + ${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"} +
+
+ + ${discord?.lastError + ? html`
+ ${discord.lastError} +
` + : nothing} + + ${discord?.probe + ? html`
+ Probe ${discord.probe.ok ? "ok" : "failed"} · + ${discord.probe.status ?? ""} + ${discord.probe.error ?? ""} +
` + : nothing} + +
+ + + + + + + + + + + + +
+ + ${props.discordTokenLocked + ? html`
+ DISCORD_BOT_TOKEN is set in the environment. Config edits will not override it. +
` + : nothing} + + ${props.discordStatus + ? html`
+ ${props.discordStatus} +
` + : nothing} + +
+ + +
+
+ `; + } + case "signal": { + const signal = data.signal; + return html` +
+
Signal
+
REST daemon status and probe details.
+ +
+
+ Configured + ${signal?.configured ? "Yes" : "No"} +
+
+ Running + ${signal?.running ? "Yes" : "No"} +
+
+ Base URL + ${signal?.baseUrl ?? "n/a"} +
+
+ Last start + ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"} +
+
+ Last probe + ${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"} +
+
+ + ${signal?.lastError + ? html`
+ ${signal.lastError} +
` + : nothing} + + ${signal?.probe + ? html`
+ Probe ${signal.probe.ok ? "ok" : "failed"} · + ${signal.probe.status ?? ""} + ${signal.probe.error ?? ""} +
` + : nothing} + +
+ + + + + + + + + + + + + +
+ + ${props.signalStatus + ? html`
+ ${props.signalStatus} +
` + : nothing} + +
+ + +
+
+ `; + } + case "imessage": { + const imessage = data.imessage; + return html` +
+
iMessage
+
imsg CLI and database availability.
+ +
+
+ Configured + ${imessage?.configured ? "Yes" : "No"} +
+
+ Running + ${imessage?.running ? "Yes" : "No"} +
+
+ CLI + ${imessage?.cliPath ?? "n/a"} +
+
+ DB + ${imessage?.dbPath ?? "n/a"} +
+
+ Last start + + ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"} + +
+
+ Last probe + + ${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"} + +
+
+ + ${imessage?.lastError + ? html`
+ ${imessage.lastError} +
` + : nothing} + + ${imessage?.probe && !imessage.probe.ok + ? html`
+ Probe failed · ${imessage.probe.error ?? "unknown error"} +
` + : nothing} + +
+ + + + + + + + +
+ + ${props.imessageStatus + ? html`
+ ${props.imessageStatus} +
` + : nothing} + +
+ + +
+
+ `; + } + default: + return nothing; + } +} diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index adb1358ed..17846e57c 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -120,7 +120,7 @@ export function renderOverview(props: OverviewProps) { ${props.lastError} ` : html`
- Use Connections to link WhatsApp and Telegram. + Use Connections to link WhatsApp, Telegram, Discord, Signal, or iMessage.
`}