diff --git a/apps/macos/Sources/Clawdis/AnyCodable+Helpers.swift b/apps/macos/Sources/Clawdis/AnyCodable+Helpers.swift new file mode 100644 index 000000000..654113b0f --- /dev/null +++ b/apps/macos/Sources/Clawdis/AnyCodable+Helpers.swift @@ -0,0 +1,22 @@ +import ClawdisProtocol +import Foundation + +extension AnyCodable { + var stringValue: String? { self.value as? String } + var boolValue: Bool? { self.value as? Bool } + var intValue: Int? { self.value as? Int } + var doubleValue: Double? { self.value as? Double } + var dictionaryValue: [String: AnyCodable]? { self.value as? [String: AnyCodable] } + var arrayValue: [AnyCodable]? { self.value as? [AnyCodable] } + + var foundationValue: Any { + switch self.value { + case let dict as [String: AnyCodable]: + dict.mapValues { $0.foundationValue } + case let array as [AnyCodable]: + array.map(\.foundationValue) + default: + self.value + } + } +} diff --git a/apps/macos/Sources/Clawdis/ConnectionsSettings.swift b/apps/macos/Sources/Clawdis/ConnectionsSettings.swift new file mode 100644 index 000000000..85a0acb6e --- /dev/null +++ b/apps/macos/Sources/Clawdis/ConnectionsSettings.swift @@ -0,0 +1,356 @@ +import AppKit +import SwiftUI + +struct ConnectionsSettings: View { + @Bindable var store: ConnectionsStore + @State private var showTelegramToken = false + + init(store: ConnectionsStore = .shared) { + self.store = store + } + + var body: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 14) { + self.header + self.whatsAppSection + self.telegramSection + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.vertical, 18) + } + .onAppear { self.store.start() } + .onDisappear { self.store.stop() } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Connections") + .font(.title3.weight(.semibold)) + Text("Link and monitor WhatsApp and Telegram providers.") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + private var whatsAppSection: some View { + GroupBox("WhatsApp") { + VStack(alignment: .leading, spacing: 10) { + self.providerHeader( + title: "WhatsApp Web", + color: self.whatsAppTint, + subtitle: self.whatsAppSummary) + + if let details = self.whatsAppDetails { + Text(details) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + 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) + + Button("Wait for scan") { + Task { await self.store.waitWhatsAppLogin() } + } + .buttonStyle(.bordered) + .disabled(self.store.whatsappBusy) + + Spacer() + + Button("Logout") { + Task { await self.store.logoutWhatsApp() } + } + .buttonStyle(.bordered) + .disabled(self.store.whatsappBusy) + + Button("Refresh") { + Task { await self.store.refresh(probe: true) } + } + .buttonStyle(.bordered) + .disabled(self.store.isRefreshing) + } + .font(.caption) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var telegramSection: some View { + GroupBox("Telegram") { + VStack(alignment: .leading, spacing: 10) { + self.providerHeader( + title: "Telegram Bot", + color: self.telegramTint, + subtitle: self.telegramSummary) + + if let details = self.telegramDetails { + 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.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) + } + 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) + } + GridRow { + self.gridLabel("Proxy") + TextField("socks5://localhost:9050", text: self.$store.telegramProxy) + .textFieldStyle(.roundedBorder) + } + 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) + } + } + + if self.isTelegramTokenLocked { + Text("Token set via TELEGRAM_BOT_TOKEN env; config edits won’t override it.") + .font(.caption) + .foregroundStyle(.secondary) + } + + 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() + + Button("Logout") { + Task { await self.store.logoutTelegram() } + } + .buttonStyle(.bordered) + .disabled(self.store.telegramBusy) + + 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 } + if status.connected { return .green } + if status.lastError != nil { return .orange } + return .green + } + + private var telegramTint: Color { + guard let status = self.store.snapshot?.telegram 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" } + 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 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 isTelegramTokenLocked: Bool { + self.store.snapshot?.telegram.tokenSource == "env" + } + + private func providerHeader(title: String, color: Color, subtitle: String) -> some View { + HStack(spacing: 10) { + Circle() + .fill(color) + .frame(width: 10, height: 10) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.headline) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + } + + private func gridLabel(_ text: String) -> some View { + Text(text) + .font(.callout.weight(.semibold)) + .frame(width: 120, 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[..? + private var configRoot: [String: Any] = [:] + private 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) async { + guard !self.whatsappBusy else { return } + self.whatsappBusy = true + defer { self.whatsappBusy = 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 + } catch { + self.whatsappLoginMessage = error.localizedDescription + self.whatsappLoginQrDataUrl = nil + self.whatsappLoginConnected = nil + } + await self.refresh(probe: true) + } + + 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 ~/.clawdis/clawdis.json." + : nil + self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:] + self.configLoaded = true + 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 ?? "" + } 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 ~/.clawdis/clawdis.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? +} diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index c28cb3262..30b7196cd 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -1053,6 +1053,13 @@ struct OnboardingView: View { title: "Open the menu bar panel", subtitle: "Click the Clawdis menu bar icon for quick chat and status.", systemImage: "bubble.left.and.bubble.right") + self.featureActionRow( + title: "Connect WhatsApp or Telegram", + subtitle: "Open Settings → Connections to link providers and monitor status.", + systemImage: "link") + { + self.openSettings(tab: .connections) + } self.featureRow( title: "Try Voice Wake", subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.", diff --git a/apps/macos/Sources/Clawdis/SettingsRootView.swift b/apps/macos/Sources/Clawdis/SettingsRootView.swift index 8843ff785..9ede06efb 100644 --- a/apps/macos/Sources/Clawdis/SettingsRootView.swift +++ b/apps/macos/Sources/Clawdis/SettingsRootView.swift @@ -21,6 +21,10 @@ struct SettingsRootView: View { .tabItem { Label("General", systemImage: "gearshape") } .tag(SettingsTab.general) + ConnectionsSettings() + .tabItem { Label("Connections", systemImage: "link") } + .tag(SettingsTab.connections) + VoiceWakeSettings(state: self.state) .tabItem { Label("Voice Wake", systemImage: "waveform.circle") } .tag(SettingsTab.voiceWake) @@ -125,12 +129,13 @@ struct SettingsRootView: View { } enum SettingsTab: CaseIterable { - case general, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about + case general, connections, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about static let windowWidth: CGFloat = 824 // wider static let windowHeight: CGFloat = 790 // +10% (more room) var title: String { switch self { case .general: "General" + case .connections: "Connections" case .skills: "Skills" case .sessions: "Sessions" case .cron: "Cron" diff --git a/docs/mac/health.md b/docs/mac/health.md index 6fa27c366..747edcf54 100644 --- a/docs/mac/health.md +++ b/docs/mac/health.md @@ -18,6 +18,7 @@ How to see whether the WhatsApp Web/Baileys bridge is healthy from the menu bar ## Settings - General tab gains a Health card showing: linked auth age, session-store path/count, last check time, last error/status code, and buttons for Run Health Check / Reveal Logs. - Uses a cached snapshot so the UI loads instantly and falls back gracefully when offline. +- **Connections tab** surfaces provider status + controls for WhatsApp/Telegram (login QR, logout, probe, last disconnect/error). ## How the probe works - App runs `clawdis health --json` via `ShellExecutor` every ~60s and on demand. The probe loads creds, attempts a short Baileys connect, and reports status without sending messages.