Update connections UIs
This commit is contained in:
parent
1d12a844c2
commit
729a545173
12 changed files with 2298 additions and 259 deletions
|
|
@ -41,6 +41,7 @@
|
|||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
||||
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
|
||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 allowFrom = Array.isArray(telegram.allowFrom)
|
||||
? (telegram.allowFrom as 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)
|
||||
? 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { html, nothing } from "lit";
|
|||
|
||||
import { formatAgo } from "../format";
|
||||
import type { ProvidersStatusSnapshot } from "../types";
|
||||
import type { TelegramForm } from "../ui-types";
|
||||
import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types";
|
||||
|
||||
export type ConnectionsProps = {
|
||||
connected: boolean;
|
||||
|
|
@ -18,20 +18,131 @@ export type ConnectionsProps = {
|
|||
telegramTokenLocked: boolean;
|
||||
telegramSaving: boolean;
|
||||
telegramStatus: string | null;
|
||||
discordForm: DiscordForm;
|
||||
discordTokenLocked: boolean;
|
||||
discordSaving: boolean;
|
||||
discordStatus: string | null;
|
||||
signalForm: SignalForm;
|
||||
signalSaving: boolean;
|
||||
signalStatus: string | null;
|
||||
imessageForm: IMessageForm;
|
||||
imessageSaving: boolean;
|
||||
imessageStatus: string | null;
|
||||
onRefresh: (probe: boolean) => void;
|
||||
onWhatsAppStart: (force: boolean) => void;
|
||||
onWhatsAppWait: () => void;
|
||||
onWhatsAppLogout: () => void;
|
||||
onTelegramChange: (patch: Partial<TelegramForm>) => void;
|
||||
onTelegramSave: () => void;
|
||||
onDiscordChange: (patch: Partial<DiscordForm>) => void;
|
||||
onDiscordSave: () => void;
|
||||
onSignalChange: (patch: Partial<SignalForm>) => void;
|
||||
onSignalSave: () => void;
|
||||
onIMessageChange: (patch: Partial<IMessageForm>) => void;
|
||||
onIMessageSave: () => void;
|
||||
};
|
||||
|
||||
export function renderConnections(props: ConnectionsProps) {
|
||||
const whatsapp = props.snapshot?.whatsapp;
|
||||
const telegram = props.snapshot?.telegram;
|
||||
const discord = props.snapshot?.discord ?? null;
|
||||
const signal = props.snapshot?.signal ?? null;
|
||||
const imessage = props.snapshot?.imessage ?? null;
|
||||
const providerOrder: ProviderKey[] = [
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
"discord",
|
||||
"signal",
|
||||
"imessage",
|
||||
];
|
||||
const orderedProviders = providerOrder
|
||||
.map((key, index) => ({
|
||||
key,
|
||||
enabled: providerEnabled(key, props),
|
||||
order: index,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
|
||||
return a.order - b.order;
|
||||
});
|
||||
|
||||
return html`
|
||||
<section class="grid grid-cols-2">
|
||||
${orderedProviders.map((provider) =>
|
||||
renderProvider(provider.key, props, { whatsapp, telegram, discord, signal, imessage }),
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 18px;">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
<div class="card-title">Connection health</div>
|
||||
<div class="card-sub">Provider status snapshots from the gateway.</div>
|
||||
</div>
|
||||
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
|
||||
</div>
|
||||
${props.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${props.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
<pre class="code-block" style="margin-top: 12px;">
|
||||
${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
|
||||
</pre>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatDuration(ms?: number | null) {
|
||||
if (!ms && ms !== 0) return "n/a";
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return `${min}m`;
|
||||
const hr = Math.round(min / 60);
|
||||
return `${hr}h`;
|
||||
}
|
||||
|
||||
type ProviderKey = "whatsapp" | "telegram" | "discord" | "signal" | "imessage";
|
||||
|
||||
function providerEnabled(key: ProviderKey, props: ConnectionsProps) {
|
||||
const snapshot = props.snapshot;
|
||||
if (!snapshot) return false;
|
||||
switch (key) {
|
||||
case "whatsapp":
|
||||
return (
|
||||
snapshot.whatsapp.configured ||
|
||||
snapshot.whatsapp.linked ||
|
||||
snapshot.whatsapp.running
|
||||
);
|
||||
case "telegram":
|
||||
return snapshot.telegram.configured || snapshot.telegram.running;
|
||||
case "discord":
|
||||
return Boolean(snapshot.discord?.configured || snapshot.discord?.running);
|
||||
case "signal":
|
||||
return Boolean(snapshot.signal?.configured || snapshot.signal?.running);
|
||||
case "imessage":
|
||||
return Boolean(snapshot.imessage?.configured || snapshot.imessage?.running);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderProvider(
|
||||
key: ProviderKey,
|
||||
props: ConnectionsProps,
|
||||
data: {
|
||||
whatsapp?: ProvidersStatusSnapshot["whatsapp"];
|
||||
telegram?: ProvidersStatusSnapshot["telegram"];
|
||||
discord?: ProvidersStatusSnapshot["discord"] | null;
|
||||
signal?: ProvidersStatusSnapshot["signal"] | null;
|
||||
imessage?: ProvidersStatusSnapshot["imessage"] | null;
|
||||
},
|
||||
) {
|
||||
switch (key) {
|
||||
case "whatsapp": {
|
||||
const whatsapp = data.whatsapp;
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">WhatsApp</div>
|
||||
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
|
||||
|
|
@ -55,11 +166,17 @@ export function renderConnections(props: ConnectionsProps) {
|
|||
</div>
|
||||
<div>
|
||||
<span class="label">Last connect</span>
|
||||
<span>${whatsapp?.lastConnectedAt ? formatAgo(whatsapp.lastConnectedAt) : "n/a"}</span>
|
||||
<span>
|
||||
${whatsapp?.lastConnectedAt
|
||||
? formatAgo(whatsapp.lastConnectedAt)
|
||||
: "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last message</span>
|
||||
<span>${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}</span>
|
||||
<span>
|
||||
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Auth age</span>
|
||||
|
|
@ -121,7 +238,11 @@ export function renderConnections(props: ConnectionsProps) {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
`;
|
||||
}
|
||||
case "telegram": {
|
||||
const telegram = data.telegram;
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Telegram</div>
|
||||
<div class="card-sub">Bot token and delivery options.</div>
|
||||
|
|
@ -271,34 +392,629 @@ export function renderConnections(props: ConnectionsProps) {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
case "discord": {
|
||||
const discord = data.discord;
|
||||
const botName = discord?.probe?.bot?.username;
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Discord</div>
|
||||
<div class="card-sub">Bot connection and probe status.</div>
|
||||
|
||||
<section class="card" style="margin-top: 18px;">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<div class="card-title">Connection health</div>
|
||||
<div class="card-sub">Provider status snapshots from the gateway.</div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${discord?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${discord?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
${props.lastError
|
||||
<div>
|
||||
<span class="label">Bot</span>
|
||||
<span>${botName ? `@${botName}` : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${discord?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${props.lastError}
|
||||
${discord.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
<pre class="code-block" style="margin-top: 12px;">
|
||||
${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
|
||||
</pre>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatDuration(ms?: number | null) {
|
||||
if (!ms && ms !== 0) return "n/a";
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return `${min}m`;
|
||||
const hr = Math.round(min / 60);
|
||||
return `${hr}h`;
|
||||
${discord?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${discord.probe.ok ? "ok" : "failed"} ·
|
||||
${discord.probe.status ?? ""}
|
||||
${discord.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="form-grid" style="margin-top: 16px;">
|
||||
<label class="field">
|
||||
<span>Enabled</span>
|
||||
<select
|
||||
.value=${props.discordForm.enabled ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
enabled: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Bot token</span>
|
||||
<input
|
||||
type="password"
|
||||
.value=${props.discordForm.token}
|
||||
?disabled=${props.discordTokenLocked}
|
||||
@input=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
token: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Allow DMs from</span>
|
||||
<input
|
||||
.value=${props.discordForm.allowFrom}
|
||||
@input=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
allowFrom: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="123456789, username#1234"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Group DMs</span>
|
||||
<select
|
||||
.value=${props.discordForm.groupEnabled ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
groupEnabled: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Enabled</option>
|
||||
<option value="no">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Group channels</span>
|
||||
<input
|
||||
.value=${props.discordForm.groupChannels}
|
||||
@input=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
groupChannels: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="channelId1, channelId2"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Media max MB</span>
|
||||
<input
|
||||
.value=${props.discordForm.mediaMaxMb}
|
||||
@input=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
mediaMaxMb: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="8"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>History limit</span>
|
||||
<input
|
||||
.value=${props.discordForm.historyLimit}
|
||||
@input=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
historyLimit: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="20"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Reactions</span>
|
||||
<select
|
||||
.value=${props.discordForm.enableReactions ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
enableReactions: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Enabled</option>
|
||||
<option value="no">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Slash command</span>
|
||||
<select
|
||||
.value=${props.discordForm.slashEnabled ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
slashEnabled: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Enabled</option>
|
||||
<option value="no">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Slash name</span>
|
||||
<input
|
||||
.value=${props.discordForm.slashName}
|
||||
@input=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
slashName: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="clawd"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Slash session prefix</span>
|
||||
<input
|
||||
.value=${props.discordForm.slashSessionPrefix}
|
||||
@input=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
slashSessionPrefix: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="discord:slash"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Slash ephemeral</span>
|
||||
<select
|
||||
.value=${props.discordForm.slashEphemeral ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
slashEphemeral: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
${props.discordTokenLocked
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
DISCORD_BOT_TOKEN is set in the environment. Config edits will not override it.
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${props.discordStatus
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
${props.discordStatus}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="row" style="margin-top: 14px;">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${props.discordSaving}
|
||||
@click=${() => props.onDiscordSave()}
|
||||
>
|
||||
${props.discordSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
case "signal": {
|
||||
const signal = data.signal;
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Signal</div>
|
||||
<div class="card-sub">REST daemon status and probe details.</div>
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${signal?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${signal?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Base URL</span>
|
||||
<span>${signal?.baseUrl ?? "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${signal?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${signal.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${signal?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${signal.probe.ok ? "ok" : "failed"} ·
|
||||
${signal.probe.status ?? ""}
|
||||
${signal.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="form-grid" style="margin-top: 16px;">
|
||||
<label class="field">
|
||||
<span>Enabled</span>
|
||||
<select
|
||||
.value=${props.signalForm.enabled ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onSignalChange({
|
||||
enabled: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Account</span>
|
||||
<input
|
||||
.value=${props.signalForm.account}
|
||||
@input=${(e: Event) =>
|
||||
props.onSignalChange({
|
||||
account: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="+15551234567"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>HTTP URL</span>
|
||||
<input
|
||||
.value=${props.signalForm.httpUrl}
|
||||
@input=${(e: Event) =>
|
||||
props.onSignalChange({
|
||||
httpUrl: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="http://127.0.0.1:8080"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>HTTP host</span>
|
||||
<input
|
||||
.value=${props.signalForm.httpHost}
|
||||
@input=${(e: Event) =>
|
||||
props.onSignalChange({
|
||||
httpHost: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="127.0.0.1"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>HTTP port</span>
|
||||
<input
|
||||
.value=${props.signalForm.httpPort}
|
||||
@input=${(e: Event) =>
|
||||
props.onSignalChange({
|
||||
httpPort: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="8080"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>CLI path</span>
|
||||
<input
|
||||
.value=${props.signalForm.cliPath}
|
||||
@input=${(e: Event) =>
|
||||
props.onSignalChange({
|
||||
cliPath: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="signal-cli"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Auto start</span>
|
||||
<select
|
||||
.value=${props.signalForm.autoStart ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onSignalChange({
|
||||
autoStart: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Receive mode</span>
|
||||
<select
|
||||
.value=${props.signalForm.receiveMode}
|
||||
@change=${(e: Event) =>
|
||||
props.onSignalChange({
|
||||
receiveMode: (e.target as HTMLSelectElement).value as
|
||||
| "on-start"
|
||||
| "manual"
|
||||
| "",
|
||||
})}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="on-start">on-start</option>
|
||||
<option value="manual">manual</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Ignore attachments</span>
|
||||
<select
|
||||
.value=${props.signalForm.ignoreAttachments ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onSignalChange({
|
||||
ignoreAttachments:
|
||||
(e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Ignore stories</span>
|
||||
<select
|
||||
.value=${props.signalForm.ignoreStories ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onSignalChange({
|
||||
ignoreStories: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Send read receipts</span>
|
||||
<select
|
||||
.value=${props.signalForm.sendReadReceipts ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onSignalChange({
|
||||
sendReadReceipts:
|
||||
(e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Allow from</span>
|
||||
<input
|
||||
.value=${props.signalForm.allowFrom}
|
||||
@input=${(e: Event) =>
|
||||
props.onSignalChange({
|
||||
allowFrom: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="12345, +1555"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Media max MB</span>
|
||||
<input
|
||||
.value=${props.signalForm.mediaMaxMb}
|
||||
@input=${(e: Event) =>
|
||||
props.onSignalChange({
|
||||
mediaMaxMb: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="8"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
${props.signalStatus
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
${props.signalStatus}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="row" style="margin-top: 14px;">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${props.signalSaving}
|
||||
@click=${() => props.onSignalSave()}
|
||||
>
|
||||
${props.signalSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
case "imessage": {
|
||||
const imessage = data.imessage;
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">iMessage</div>
|
||||
<div class="card-sub">imsg CLI and database availability.</div>
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${imessage?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${imessage?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">CLI</span>
|
||||
<span>${imessage?.cliPath ?? "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">DB</span>
|
||||
<span>${imessage?.dbPath ?? "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>
|
||||
${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>
|
||||
${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${imessage?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${imessage.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${imessage?.probe && !imessage.probe.ok
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe failed · ${imessage.probe.error ?? "unknown error"}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="form-grid" style="margin-top: 16px;">
|
||||
<label class="field">
|
||||
<span>Enabled</span>
|
||||
<select
|
||||
.value=${props.imessageForm.enabled ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onIMessageChange({
|
||||
enabled: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>CLI path</span>
|
||||
<input
|
||||
.value=${props.imessageForm.cliPath}
|
||||
@input=${(e: Event) =>
|
||||
props.onIMessageChange({
|
||||
cliPath: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="imsg"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>DB path</span>
|
||||
<input
|
||||
.value=${props.imessageForm.dbPath}
|
||||
@input=${(e: Event) =>
|
||||
props.onIMessageChange({
|
||||
dbPath: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="~/Library/Messages/chat.db"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Service</span>
|
||||
<select
|
||||
.value=${props.imessageForm.service}
|
||||
@change=${(e: Event) =>
|
||||
props.onIMessageChange({
|
||||
service: (e.target as HTMLSelectElement).value as
|
||||
| "auto"
|
||||
| "imessage"
|
||||
| "sms",
|
||||
})}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="imessage">iMessage</option>
|
||||
<option value="sms">SMS</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Region</span>
|
||||
<input
|
||||
.value=${props.imessageForm.region}
|
||||
@input=${(e: Event) =>
|
||||
props.onIMessageChange({
|
||||
region: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="US"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Allow from</span>
|
||||
<input
|
||||
.value=${props.imessageForm.allowFrom}
|
||||
@input=${(e: Event) =>
|
||||
props.onIMessageChange({
|
||||
allowFrom: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="chat_id:101, +1555"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Include attachments</span>
|
||||
<select
|
||||
.value=${props.imessageForm.includeAttachments ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onIMessageChange({
|
||||
includeAttachments:
|
||||
(e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Media max MB</span>
|
||||
<input
|
||||
.value=${props.imessageForm.mediaMaxMb}
|
||||
@input=${(e: Event) =>
|
||||
props.onIMessageChange({
|
||||
mediaMaxMb: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="16"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
${props.imessageStatus
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
${props.imessageStatus}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="row" style="margin-top: 14px;">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${props.imessageSaving}
|
||||
@click=${() => props.onIMessageSave()}
|
||||
>
|
||||
${props.imessageSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
default:
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue