Files
clawdbot/apps/macos/Sources/Clawdis/ConnectionsStore.swift
2026-01-03 03:07:13 +01:00

993 lines
36 KiB
Swift

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?
}
struct DiscordBot: Codable {
let id: String?
let username: String?
}
struct DiscordProbe: Codable {
let ok: Bool
let status: Int?
let error: String?
let elapsedMs: Double?
let bot: DiscordBot?
}
struct DiscordStatus: Codable {
let configured: Bool
let tokenSource: String?
let running: Bool
let lastStartAt: Double?
let lastStopAt: Double?
let lastError: String?
let probe: DiscordProbe?
let lastProbeAt: Double?
}
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 {
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 discordEnabled = true
var discordToken: String = ""
var discordAllowFrom: String = ""
var discordGroupEnabled = false
var discordGroupChannels: String = ""
var discordMediaMaxMb: String = ""
var discordHistoryLimit: String = ""
var discordActionReactions = true
var discordActionStickers = true
var discordActionPolls = true
var discordActionPermissions = true
var discordActionMessages = true
var discordActionThreads = true
var discordActionPins = true
var discordActionSearch = true
var discordActionMemberInfo = true
var discordActionRoleInfo = true
var discordActionChannelInfo = true
var discordActionVoiceStatus = true
var discordActionEvents = true
var discordActionRoles = false
var discordActionModeration = false
var discordSlashEnabled = false
var discordSlashName: String = ""
var discordSlashSessionPrefix: String = ""
var discordSlashEphemeral = true
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
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, autoWait: Bool = true) async {
guard !self.whatsappBusy else { return }
self.whatsappBusy = true
defer { self.whatsappBusy = false }
var shouldAutoWait = 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
shouldAutoWait = autoWait && result.qrDataUrl != nil
} catch {
self.whatsappLoginMessage = error.localizedDescription
self.whatsappLoginQrDataUrl = nil
self.whatsappLoginConnected = nil
}
await self.refresh(probe: true)
if shouldAutoWait {
Task { await self.waitWhatsAppLogin() }
}
}
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 ui = snap.config?["ui"]?.dictionaryValue
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
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 ?? ""
let discord = snap.config?["discord"]?.dictionaryValue
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
self.discordToken = discord?["token"]?.stringValue ?? ""
let discordDm = discord?["dm"]?.dictionaryValue
if let allow = discordDm?["allowFrom"]?.arrayValue {
let strings = allow.compactMap { entry -> String? in
if let str = entry.stringValue { return str }
if let intVal = entry.intValue { return String(intVal) }
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
return nil
}
self.discordAllowFrom = strings.joined(separator: ", ")
} else {
self.discordAllowFrom = ""
}
self.discordGroupEnabled = discordDm?["groupEnabled"]?.boolValue ?? false
if let channels = discordDm?["groupChannels"]?.arrayValue {
let strings = channels.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.discordGroupChannels = strings.joined(separator: ", ")
} else {
self.discordGroupChannels = ""
}
if let media = discord?["mediaMaxMb"]?.doubleValue ?? discord?["mediaMaxMb"]?.intValue.map(Double.init) {
self.discordMediaMaxMb = String(Int(media))
} else {
self.discordMediaMaxMb = ""
}
if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue.map(Double.init) {
self.discordHistoryLimit = String(Int(history))
} else {
self.discordHistoryLimit = ""
}
let discordActions = discord?["actions"]?.dictionaryValue
self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true
self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true
self.discordActionPolls = discordActions?["polls"]?.boolValue ?? true
self.discordActionPermissions = discordActions?["permissions"]?.boolValue ?? true
self.discordActionMessages = discordActions?["messages"]?.boolValue ?? true
self.discordActionThreads = discordActions?["threads"]?.boolValue ?? true
self.discordActionPins = discordActions?["pins"]?.boolValue ?? true
self.discordActionSearch = discordActions?["search"]?.boolValue ?? true
self.discordActionMemberInfo = discordActions?["memberInfo"]?.boolValue ?? true
self.discordActionRoleInfo = discordActions?["roleInfo"]?.boolValue ?? true
self.discordActionChannelInfo = discordActions?["channelInfo"]?.boolValue ?? true
self.discordActionVoiceStatus = discordActions?["voiceStatus"]?.boolValue ?? true
self.discordActionEvents = discordActions?["events"]?.boolValue ?? true
self.discordActionRoles = discordActions?["roles"]?.boolValue ?? false
self.discordActionModeration = discordActions?["moderation"]?.boolValue ?? false
let slash = discord?["slashCommand"]?.dictionaryValue
self.discordSlashEnabled = slash?["enabled"]?.boolValue ?? false
self.discordSlashName = slash?["name"]?.stringValue ?? ""
self.discordSlashSessionPrefix = slash?["sessionPrefix"]?.stringValue ?? ""
self.discordSlashEphemeral = slash?["ephemeral"]?.boolValue ?? true
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
}
}
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
}
}
func saveDiscordConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:]
if self.discordEnabled {
discord.removeValue(forKey: "enabled")
} else {
discord["enabled"] = false
}
let token = self.discordToken.trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
discord.removeValue(forKey: "token")
} else {
discord["token"] = token
}
var dm: [String: Any] = (discord["dm"] as? [String: Any]) ?? [:]
let allow = self.discordAllowFrom
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if allow.isEmpty {
dm.removeValue(forKey: "allowFrom")
} else {
dm["allowFrom"] = allow
}
if self.discordGroupEnabled {
dm["groupEnabled"] = true
} else {
dm.removeValue(forKey: "groupEnabled")
}
let groupChannels = self.discordGroupChannels
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if groupChannels.isEmpty {
dm.removeValue(forKey: "groupChannels")
} else {
dm["groupChannels"] = groupChannels
}
if dm.isEmpty {
discord.removeValue(forKey: "dm")
} else {
discord["dm"] = dm
}
let media = self.discordMediaMaxMb.trimmingCharacters(in: .whitespacesAndNewlines)
if media.isEmpty {
discord.removeValue(forKey: "mediaMaxMb")
} else if let value = Double(media) {
discord["mediaMaxMb"] = value
}
let history = self.discordHistoryLimit.trimmingCharacters(in: .whitespacesAndNewlines)
if history.isEmpty {
discord.removeValue(forKey: "historyLimit")
} else if let value = Int(history), value >= 0 {
discord["historyLimit"] = value
} else {
discord.removeValue(forKey: "historyLimit")
}
var actions: [String: Any] = (discord["actions"] as? [String: Any]) ?? [:]
func setAction(_ key: String, value: Bool, defaultValue: Bool) {
if value == defaultValue {
actions.removeValue(forKey: key)
} else {
actions[key] = value
}
}
setAction("reactions", value: self.discordActionReactions, defaultValue: true)
setAction("stickers", value: self.discordActionStickers, defaultValue: true)
setAction("polls", value: self.discordActionPolls, defaultValue: true)
setAction("permissions", value: self.discordActionPermissions, defaultValue: true)
setAction("messages", value: self.discordActionMessages, defaultValue: true)
setAction("threads", value: self.discordActionThreads, defaultValue: true)
setAction("pins", value: self.discordActionPins, defaultValue: true)
setAction("search", value: self.discordActionSearch, defaultValue: true)
setAction("memberInfo", value: self.discordActionMemberInfo, defaultValue: true)
setAction("roleInfo", value: self.discordActionRoleInfo, defaultValue: true)
setAction("channelInfo", value: self.discordActionChannelInfo, defaultValue: true)
setAction("voiceStatus", value: self.discordActionVoiceStatus, defaultValue: true)
setAction("events", value: self.discordActionEvents, defaultValue: true)
setAction("roles", value: self.discordActionRoles, defaultValue: false)
setAction("moderation", value: self.discordActionModeration, defaultValue: false)
if actions.isEmpty {
discord.removeValue(forKey: "actions")
} else {
discord["actions"] = actions
}
var slash: [String: Any] = (discord["slashCommand"] as? [String: Any]) ?? [:]
if self.discordSlashEnabled {
slash["enabled"] = true
} else {
slash.removeValue(forKey: "enabled")
}
let slashName = self.discordSlashName.trimmingCharacters(in: .whitespacesAndNewlines)
if slashName.isEmpty {
slash.removeValue(forKey: "name")
} else {
slash["name"] = slashName
}
let slashPrefix = self.discordSlashSessionPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
if slashPrefix.isEmpty {
slash.removeValue(forKey: "sessionPrefix")
} else {
slash["sessionPrefix"] = slashPrefix
}
if self.discordSlashEphemeral {
slash.removeValue(forKey: "ephemeral")
} else {
slash["ephemeral"] = false
}
if slash.isEmpty {
discord.removeValue(forKey: "slashCommand")
} else {
discord["slashCommand"] = slash
}
if discord.isEmpty {
self.configRoot.removeValue(forKey: "discord")
} else {
self.configRoot["discord"] = discord
}
do {
let data = try JSONSerialization.data(
withJSONObject: self.configRoot,
options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
self.configStatus = "Failed to encode config."
return
}
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
_ = try await GatewayConnection.shared.requestRaw(
method: .configSet,
params: params,
timeoutMs: 10000)
self.configStatus = "Saved to ~/.clawdis/clawdis.json."
await self.refresh(probe: true)
} catch {
self.configStatus = error.localizedDescription
}
}
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 {
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?
}