Update connections UIs

This commit is contained in:
Shadow
2026-01-02 12:06:05 -06:00
parent 1d12a844c2
commit 729a545173
12 changed files with 2298 additions and 259 deletions

View File

@@ -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`; dont 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`.

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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

View File

@@ -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}

View File

@@ -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);
}

View File

@@ -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<string, unknown>;
const discord = (config.discord ?? {}) as Record<string, unknown>;
const signal = (config.signal ?? {}) as Record<string, unknown>;
const imessage = (config.imessage ?? {}) as Record<string, unknown>;
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<string, unknown>;
const slash = (discord.slashCommand ?? {}) as Record<string, unknown>;
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;
}
}

View File

@@ -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<DiscordForm>,
) {
state.discordForm = { ...state.discordForm, ...patch };
}
export function updateSignalForm(
state: ConnectionsState,
patch: Partial<SignalForm>,
) {
state.signalForm = { ...state.signalForm, ...patch };
}
export function updateIMessageForm(
state: ConnectionsState,
patch: Partial<IMessageForm>,
) {
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<string, unknown>;
const discord = { ...(config.discord ?? {}) } as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const signal = { ...(config.signal ?? {}) } as Record<string, unknown>;
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<string, unknown>;
const imessage = { ...(config.imessage ?? {}) } as Record<string, unknown>;
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;
}
}

View File

@@ -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;

View File

@@ -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;
};

File diff suppressed because it is too large Load Diff

View File

@@ -120,7 +120,7 @@ export function renderOverview(props: OverviewProps) {
${props.lastError}
</div>`
: html`<div class="callout" style="margin-top: 14px;">
Use Connections to link WhatsApp and Telegram.
Use Connections to link WhatsApp, Telegram, Discord, Signal, or iMessage.
</div>`}
</div>
</section>