feat(macos): add connections settings
# Conflicts: # apps/macos/Sources/Clawdis/SettingsRootView.swift
This commit is contained in:
22
apps/macos/Sources/Clawdis/AnyCodable+Helpers.swift
Normal file
22
apps/macos/Sources/Clawdis/AnyCodable+Helpers.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
356
apps/macos/Sources/Clawdis/ConnectionsSettings.swift
Normal file
356
apps/macos/Sources/Clawdis/ConnectionsSettings.swift
Normal file
@@ -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[..<comma]
|
||||
guard header.contains("base64") else { return nil }
|
||||
let base64 = dataUrl[dataUrl.index(after: comma)...]
|
||||
guard let data = Data(base64Encoded: String(base64)) else { return nil }
|
||||
return NSImage(data: data)
|
||||
}
|
||||
}
|
||||
388
apps/macos/Sources/Clawdis/ConnectionsStore.swift
Normal file
388
apps/macos/Sources/Clawdis/ConnectionsStore.swift
Normal file
@@ -0,0 +1,388 @@
|
||||
import ClawdisProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct ProvidersStatusSnapshot: Codable {
|
||||
struct WhatsAppSelf: Codable {
|
||||
let e164: String?
|
||||
let jid: String?
|
||||
}
|
||||
|
||||
struct WhatsAppDisconnect: Codable {
|
||||
let at: Double
|
||||
let status: Int?
|
||||
let error: String?
|
||||
let loggedOut: Bool?
|
||||
}
|
||||
|
||||
struct WhatsAppStatus: Codable {
|
||||
let configured: Bool
|
||||
let linked: Bool
|
||||
let authAgeMs: Double?
|
||||
let `self`: WhatsAppSelf?
|
||||
let running: Bool
|
||||
let connected: Bool
|
||||
let lastConnectedAt: Double?
|
||||
let lastDisconnect: WhatsAppDisconnect?
|
||||
let reconnectAttempts: Int
|
||||
let lastMessageAt: Double?
|
||||
let lastEventAt: Double?
|
||||
let lastError: String?
|
||||
}
|
||||
|
||||
struct TelegramBot: Codable {
|
||||
let id: Int?
|
||||
let username: String?
|
||||
}
|
||||
|
||||
struct TelegramWebhook: Codable {
|
||||
let url: String?
|
||||
let hasCustomCert: Bool?
|
||||
}
|
||||
|
||||
struct TelegramProbe: Codable {
|
||||
let ok: Bool
|
||||
let status: Int?
|
||||
let error: String?
|
||||
let elapsedMs: Double?
|
||||
let bot: TelegramBot?
|
||||
let webhook: TelegramWebhook?
|
||||
}
|
||||
|
||||
struct TelegramStatus: Codable {
|
||||
let configured: Bool
|
||||
let tokenSource: String?
|
||||
let running: Bool
|
||||
let mode: String?
|
||||
let lastStartAt: Double?
|
||||
let lastStopAt: Double?
|
||||
let lastError: String?
|
||||
let probe: TelegramProbe?
|
||||
let lastProbeAt: Double?
|
||||
}
|
||||
|
||||
let ts: Double
|
||||
let whatsapp: WhatsAppStatus
|
||||
let telegram: TelegramStatus
|
||||
}
|
||||
|
||||
struct ConfigSnapshot: Codable {
|
||||
struct Issue: Codable {
|
||||
let path: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
let path: String?
|
||||
let exists: Bool?
|
||||
let raw: String?
|
||||
let parsed: AnyCodable?
|
||||
let valid: Bool?
|
||||
let config: [String: AnyCodable]?
|
||||
let issues: [Issue]?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ConnectionsStore {
|
||||
static let shared = ConnectionsStore()
|
||||
|
||||
var snapshot: ProvidersStatusSnapshot?
|
||||
var lastError: String?
|
||||
var lastSuccess: Date?
|
||||
var isRefreshing = false
|
||||
|
||||
var whatsappLoginMessage: String?
|
||||
var whatsappLoginQrDataUrl: String?
|
||||
var whatsappLoginConnected: Bool?
|
||||
var whatsappBusy = false
|
||||
|
||||
var telegramToken: String = ""
|
||||
var telegramRequireMention = true
|
||||
var telegramAllowFrom: String = ""
|
||||
var telegramProxy: String = ""
|
||||
var telegramWebhookUrl: String = ""
|
||||
var telegramWebhookSecret: String = ""
|
||||
var telegramWebhookPath: String = ""
|
||||
var telegramBusy = false
|
||||
var configStatus: String?
|
||||
var isSavingConfig = false
|
||||
|
||||
private let interval: TimeInterval = 45
|
||||
private let isPreview: Bool
|
||||
private var pollTask: Task<Void, Never>?
|
||||
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?
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user