Config: schema-driven channels and settings
This commit is contained in:
committed by
Peter Steinberger
parent
bcfc9bead5
commit
1ad26d6fea
311
apps/macos/Sources/Clawdbot/ChannelConfigForm.swift
Normal file
311
apps/macos/Sources/Clawdbot/ChannelConfigForm.swift
Normal file
@@ -0,0 +1,311 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConfigSchemaForm: View {
|
||||
@Bindable var store: ChannelsStore
|
||||
let schema: ConfigSchemaNode
|
||||
let path: ConfigPath
|
||||
|
||||
var body: some View {
|
||||
self.renderNode(schema, path: path)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> some View {
|
||||
let value = store.configValue(at: path)
|
||||
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
|
||||
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
|
||||
|
||||
switch schema.schemaType {
|
||||
case "object":
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let label {
|
||||
Text(label)
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
let properties = schema.properties
|
||||
let sortedKeys = properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0
|
||||
let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
}
|
||||
ForEach(sortedKeys, id: \ .self) { key in
|
||||
if let child = properties[key] {
|
||||
self.renderNode(child, path: path + [.key(key)])
|
||||
}
|
||||
}
|
||||
if schema.allowsAdditionalProperties {
|
||||
self.renderAdditionalProperties(schema, path: path, value: value)
|
||||
}
|
||||
}
|
||||
case "array":
|
||||
self.renderArray(schema, path: path, value: value, label: label, help: help)
|
||||
case "boolean":
|
||||
Toggle(isOn: self.boolBinding(path)) {
|
||||
if let label { Text(label) } else { Text("Enabled") }
|
||||
}
|
||||
.help(help ?? "")
|
||||
case "number", "integer":
|
||||
self.renderNumberField(schema, path: path, label: label, help: help)
|
||||
case "string":
|
||||
self.renderStringField(schema, path: path, label: label, help: help)
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
Text("Unsupported field type.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderStringField(
|
||||
_ schema: ConfigSchemaNode,
|
||||
path: ConfigPath,
|
||||
label: String?,
|
||||
help: String?) -> some View
|
||||
{
|
||||
let hint = hintForPath(path, hints: store.configUiHints)
|
||||
let placeholder = hint?.placeholder ?? ""
|
||||
let sensitive = hint?.sensitive ?? false
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let options = schema.enumValues {
|
||||
Picker("", selection: self.enumBinding(path, options: options)) {
|
||||
Text("Select…").tag(-1)
|
||||
ForEach(options.indices, id: \ .self) { index in
|
||||
Text(String(describing: options[index])).tag(index)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
} else if sensitive {
|
||||
SecureField(placeholder, text: self.stringBinding(path))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
} else {
|
||||
TextField(placeholder, text: self.stringBinding(path))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderNumberField(
|
||||
_ schema: ConfigSchemaNode,
|
||||
path: ConfigPath,
|
||||
label: String?,
|
||||
help: String?) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
TextField("", text: self.numberBinding(path, isInteger: schema.schemaType == "integer"))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderArray(
|
||||
_ schema: ConfigSchemaNode,
|
||||
path: ConfigPath,
|
||||
value: Any?,
|
||||
label: String?,
|
||||
help: String?) -> some View
|
||||
{
|
||||
let items = value as? [Any] ?? []
|
||||
let itemSchema = schema.items
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(items.indices, id: \ .self) { index in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
if let itemSchema {
|
||||
self.renderNode(itemSchema, path: path + [.index(index)])
|
||||
} else {
|
||||
Text(String(describing: items[index]))
|
||||
}
|
||||
Button("Remove") {
|
||||
var next = items
|
||||
next.remove(at: index)
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
Button("Add") {
|
||||
var next = items
|
||||
if let itemSchema {
|
||||
next.append(itemSchema.defaultValue)
|
||||
} else {
|
||||
next.append("")
|
||||
}
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderAdditionalProperties(
|
||||
_ schema: ConfigSchemaNode,
|
||||
path: ConfigPath,
|
||||
value: Any?) -> some View
|
||||
{
|
||||
guard let additionalSchema = schema.additionalProperties else { return }
|
||||
let dict = value as? [String: Any] ?? [:]
|
||||
let reserved = Set(schema.properties.keys)
|
||||
let extras = dict.keys.filter { !reserved.contains($0) }.sorted()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Extra entries")
|
||||
.font(.callout.weight(.semibold))
|
||||
if extras.isEmpty {
|
||||
Text("No extra entries yet.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(extras, id: \ .self) { key in
|
||||
let itemPath: ConfigPath = path + [.key(key)]
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
TextField("Key", text: self.mapKeyBinding(path: path, key: key))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 160)
|
||||
self.renderNode(additionalSchema, path: itemPath)
|
||||
Button("Remove") {
|
||||
var next = dict
|
||||
next.removeValue(forKey: key)
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Add") {
|
||||
var next = dict
|
||||
var index = 1
|
||||
var key = "new-\(index)"
|
||||
while next[key] != nil {
|
||||
index += 1
|
||||
key = "new-\(index)"
|
||||
}
|
||||
next[key] = additionalSchema.defaultValue
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private func stringBinding(_ path: ConfigPath) -> Binding<String> {
|
||||
Binding(
|
||||
get: {
|
||||
store.configValue(at: path) as? String ?? ""
|
||||
},
|
||||
set: { newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func boolBinding(_ path: ConfigPath) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: {
|
||||
store.configValue(at: path) as? Bool ?? false
|
||||
},
|
||||
set: { newValue in
|
||||
store.updateConfigValue(path: path, value: newValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func numberBinding(_ path: ConfigPath, isInteger: Bool) -> Binding<String> {
|
||||
Binding(
|
||||
get: {
|
||||
guard let value = store.configValue(at: path) else { return "" }
|
||||
return String(describing: value)
|
||||
},
|
||||
set: { newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
store.updateConfigValue(path: path, value: nil)
|
||||
} else if let value = Double(trimmed) {
|
||||
store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func enumBinding(_ path: ConfigPath, options: [Any]) -> Binding<Int> {
|
||||
Binding(
|
||||
get: {
|
||||
guard let value = store.configValue(at: path) else { return -1 }
|
||||
return options.firstIndex { option in
|
||||
String(describing: option) == String(describing: value)
|
||||
} ?? -1
|
||||
},
|
||||
set: { index in
|
||||
guard index >= 0, index < options.count else {
|
||||
store.updateConfigValue(path: path, value: nil)
|
||||
return
|
||||
}
|
||||
store.updateConfigValue(path: path, value: options[index])
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
|
||||
Binding(
|
||||
get: { key },
|
||||
set: { newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
guard trimmed != key else { return }
|
||||
let current = store.configValue(at: path) as? [String: Any] ?? [:]
|
||||
guard current[trimmed] == nil else { return }
|
||||
var next = current
|
||||
next[trimmed] = current[key]
|
||||
next.removeValue(forKey: key)
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChannelConfigForm: View {
|
||||
@Bindable var store: ChannelsStore
|
||||
let channelId: String
|
||||
|
||||
var body: some View {
|
||||
if store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let schema = store.channelConfigSchema(for: channelId) {
|
||||
ConfigSchemaForm(store: store, schema: schema, path: [.key("channels"), .key(channelId)])
|
||||
} else {
|
||||
Text("Schema unavailable for this channel.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import SwiftUI
|
||||
|
||||
extension ChannelsSettings {
|
||||
func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
||||
GroupBox(title) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func channelHeaderActions(_ channel: ChannelItem) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
if channel.id == "whatsapp" {
|
||||
Button("Logout") {
|
||||
Task { await self.store.logoutWhatsApp() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.whatsappBusy)
|
||||
}
|
||||
|
||||
if channel.id == "telegram" {
|
||||
Button("Logout") {
|
||||
Task { await self.store.logoutTelegram() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.telegramBusy)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await self.store.refresh(probe: true) }
|
||||
} label: {
|
||||
if self.store.isRefreshing {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Refresh")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.isRefreshing)
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
var whatsAppSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.formSection("Linking") {
|
||||
if let message = self.store.whatsappLoginMessage {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.interpolation(.none)
|
||||
.frame(width: 180, height: 180)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.store.startWhatsAppLogin(force: false) }
|
||||
} label: {
|
||||
if self.store.whatsappBusy {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Show QR")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.store.whatsappBusy)
|
||||
|
||||
Button("Relink") {
|
||||
Task { await self.store.startWhatsAppLogin(force: true) }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.whatsappBusy)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
self.configEditorSection(channelId: "whatsapp")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func genericChannelSection(_ channel: ChannelItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.configEditorSection(channelId: channel.id)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func configEditorSection(channelId: String) -> some View {
|
||||
self.formSection("Configuration") {
|
||||
ChannelConfigForm(store: self.store, channelId: channelId)
|
||||
}
|
||||
|
||||
self.configStatusMessage
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.store.saveConfigDraft() }
|
||||
} label: {
|
||||
if self.store.isSavingConfig {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.store.isSavingConfig || !self.store.configDirty)
|
||||
|
||||
Button("Reload") {
|
||||
Task { await self.store.reloadConfigDraft() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.isSavingConfig)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var configStatusMessage: some View {
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import ClawdbotProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension ConnectionsSettings {
|
||||
extension ChannelsSettings {
|
||||
private func channelStatus<T: Decodable>(
|
||||
_ id: String,
|
||||
as type: T.Type) -> T?
|
||||
@@ -242,16 +243,18 @@ extension ConnectionsSettings {
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var isTelegramTokenLocked: Bool {
|
||||
self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
|
||||
}
|
||||
|
||||
var isDiscordTokenLocked: Bool {
|
||||
self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
|
||||
}
|
||||
|
||||
var orderedChannels: [ConnectionChannel] {
|
||||
ConnectionChannel.allCases.sorted { lhs, rhs in
|
||||
var orderedChannels: [ChannelItem] {
|
||||
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
|
||||
let order = self.store.snapshot?.channelOrder ?? fallback
|
||||
let channels = order.enumerated().map { index, id in
|
||||
ChannelItem(
|
||||
id: id,
|
||||
title: self.resolveChannelTitle(id),
|
||||
detailTitle: self.resolveChannelDetailTitle(id),
|
||||
systemImage: self.resolveChannelSystemImage(id),
|
||||
sortOrder: index)
|
||||
}
|
||||
return channels.sorted { lhs, rhs in
|
||||
let lhsEnabled = self.channelEnabled(lhs)
|
||||
let rhsEnabled = self.channelEnabled(rhs)
|
||||
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
|
||||
@@ -259,11 +262,11 @@ extension ConnectionsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
var enabledChannels: [ConnectionChannel] {
|
||||
var enabledChannels: [ChannelItem] {
|
||||
self.orderedChannels.filter { self.channelEnabled($0) }
|
||||
}
|
||||
|
||||
var availableChannels: [ConnectionChannel] {
|
||||
var availableChannels: [ChannelItem] {
|
||||
self.orderedChannels.filter { !self.channelEnabled($0) }
|
||||
}
|
||||
|
||||
@@ -277,143 +280,183 @@ extension ConnectionsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
func channelEnabled(_ channel: ConnectionChannel) -> Bool {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.linked || status.running
|
||||
case .telegram:
|
||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
case .discord:
|
||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
case .signal:
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
case .imessage:
|
||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
}
|
||||
func channelEnabled(_ channel: ChannelItem) -> Bool {
|
||||
let status = self.channelStatusDictionary(channel.id)
|
||||
let configured = status?["configured"]?.boolValue ?? false
|
||||
let running = status?["running"]?.boolValue ?? false
|
||||
let connected = status?["connected"]?.boolValue ?? false
|
||||
let accountActive = self.store.snapshot?.channelAccounts[channel.id]?.contains(
|
||||
where: { $0.configured == true || $0.running == true || $0.connected == true }) ?? false
|
||||
return configured || running || connected || accountActive
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func channelSection(_ channel: ConnectionChannel) -> some View {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
func channelSection(_ channel: ChannelItem) -> some View {
|
||||
if channel.id == "whatsapp" {
|
||||
self.whatsAppSection
|
||||
case .telegram:
|
||||
self.telegramSection
|
||||
case .discord:
|
||||
self.discordSection
|
||||
case .signal:
|
||||
self.signalSection
|
||||
case .imessage:
|
||||
self.imessageSection
|
||||
} else {
|
||||
self.genericChannelSection(channel)
|
||||
}
|
||||
}
|
||||
|
||||
func channelTint(_ channel: ConnectionChannel) -> Color {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
self.whatsAppTint
|
||||
case .telegram:
|
||||
self.telegramTint
|
||||
case .discord:
|
||||
self.discordTint
|
||||
case .signal:
|
||||
self.signalTint
|
||||
case .imessage:
|
||||
self.imessageTint
|
||||
func channelTint(_ channel: ChannelItem) -> Color {
|
||||
switch channel.id {
|
||||
case "whatsapp":
|
||||
return self.whatsAppTint
|
||||
case "telegram":
|
||||
return self.telegramTint
|
||||
case "discord":
|
||||
return self.discordTint
|
||||
case "signal":
|
||||
return self.signalTint
|
||||
case "imessage":
|
||||
return self.imessageTint
|
||||
default:
|
||||
if self.channelHasError(channel) { return .orange }
|
||||
if self.channelEnabled(channel) { return .green }
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
func channelSummary(_ channel: ConnectionChannel) -> String {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
self.whatsAppSummary
|
||||
case .telegram:
|
||||
self.telegramSummary
|
||||
case .discord:
|
||||
self.discordSummary
|
||||
case .signal:
|
||||
self.signalSummary
|
||||
case .imessage:
|
||||
self.imessageSummary
|
||||
func channelSummary(_ channel: ChannelItem) -> String {
|
||||
switch channel.id {
|
||||
case "whatsapp":
|
||||
return self.whatsAppSummary
|
||||
case "telegram":
|
||||
return self.telegramSummary
|
||||
case "discord":
|
||||
return self.discordSummary
|
||||
case "signal":
|
||||
return self.signalSummary
|
||||
case "imessage":
|
||||
return self.imessageSummary
|
||||
default:
|
||||
if self.channelHasError(channel) { return "Error" }
|
||||
if self.channelEnabled(channel) { return "Active" }
|
||||
return "Not configured"
|
||||
}
|
||||
}
|
||||
|
||||
func channelDetails(_ channel: ConnectionChannel) -> String? {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
self.whatsAppDetails
|
||||
case .telegram:
|
||||
self.telegramDetails
|
||||
case .discord:
|
||||
self.discordDetails
|
||||
case .signal:
|
||||
self.signalDetails
|
||||
case .imessage:
|
||||
self.imessageDetails
|
||||
func channelDetails(_ channel: ChannelItem) -> String? {
|
||||
switch channel.id {
|
||||
case "whatsapp":
|
||||
return self.whatsAppDetails
|
||||
case "telegram":
|
||||
return self.telegramDetails
|
||||
case "discord":
|
||||
return self.discordDetails
|
||||
case "signal":
|
||||
return self.signalDetails
|
||||
case "imessage":
|
||||
return self.imessageDetails
|
||||
default:
|
||||
let status = self.channelStatusDictionary(channel.id)
|
||||
if let err = status?["lastError"]?.stringValue, !err.isEmpty {
|
||||
return "Error: \(err)"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func channelLastCheckText(_ channel: ConnectionChannel) -> String {
|
||||
func channelLastCheckText(_ channel: ChannelItem) -> String {
|
||||
guard let date = self.channelLastCheck(channel) else { return "never" }
|
||||
return relativeAge(from: date)
|
||||
}
|
||||
|
||||
func channelLastCheck(_ channel: ConnectionChannel) -> Date? {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
func channelLastCheck(_ channel: ChannelItem) -> Date? {
|
||||
switch channel.id {
|
||||
case "whatsapp":
|
||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return nil }
|
||||
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
|
||||
case .telegram:
|
||||
case "telegram":
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
|
||||
.lastProbeAt)
|
||||
case .discord:
|
||||
case "discord":
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||
.lastProbeAt)
|
||||
case .signal:
|
||||
case "signal":
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
||||
case .imessage:
|
||||
case "imessage":
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
|
||||
.lastProbeAt)
|
||||
default:
|
||||
let status = self.channelStatusDictionary(channel.id)
|
||||
if let probeAt = status?["lastProbeAt"]?.doubleValue {
|
||||
return self.date(fromMs: probeAt)
|
||||
}
|
||||
if let accounts = self.store.snapshot?.channelAccounts[channel.id] {
|
||||
let last = accounts.compactMap { $0.lastInboundAt ?? $0.lastOutboundAt }.max()
|
||||
return self.date(fromMs: last)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func channelHasError(_ channel: ConnectionChannel) -> Bool {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
func channelHasError(_ channel: ChannelItem) -> Bool {
|
||||
switch channel.id {
|
||||
case "whatsapp":
|
||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
|
||||
case .telegram:
|
||||
case "telegram":
|
||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case .discord:
|
||||
case "discord":
|
||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case .signal:
|
||||
case "signal":
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case .imessage:
|
||||
case "imessage":
|
||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
default:
|
||||
let status = self.channelStatusDictionary(channel.id)
|
||||
return status?["lastError"]?.stringValue?.isEmpty == false
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveChannelTitle(_ id: String) -> String {
|
||||
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
|
||||
return label
|
||||
}
|
||||
return id.prefix(1).uppercased() + id.dropFirst()
|
||||
}
|
||||
|
||||
private func resolveChannelDetailTitle(_ id: String) -> String {
|
||||
switch id {
|
||||
case "whatsapp": return "WhatsApp Web"
|
||||
case "telegram": return "Telegram Bot"
|
||||
case "discord": return "Discord Bot"
|
||||
case "slack": return "Slack Bot"
|
||||
case "signal": return "Signal REST"
|
||||
case "imessage": return "iMessage"
|
||||
default: return self.resolveChannelTitle(id)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||
switch id {
|
||||
case "whatsapp": return "message"
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
case "slack": return "number"
|
||||
case "signal": return "antenna.radiowaves.left.and.right"
|
||||
case "imessage": return "message.fill"
|
||||
default: return "message"
|
||||
}
|
||||
}
|
||||
|
||||
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
||||
self.store.snapshot?.channels[id]?.dictionaryValue
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import AppKit
|
||||
|
||||
extension ConnectionsSettings {
|
||||
extension ChannelsSettings {
|
||||
func date(fromMs ms: Double?) -> Date? {
|
||||
guard let ms else { return nil }
|
||||
return Date(timeIntervalSince1970: ms / 1000)
|
||||
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
extension ConnectionsSettings {
|
||||
extension ChannelsSettings {
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
self.sidebar
|
||||
@@ -57,7 +57,7 @@ extension ConnectionsSettings {
|
||||
|
||||
private var emptyDetail: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Connections")
|
||||
Text("Channels")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Select a channel to view status and settings.")
|
||||
.font(.callout)
|
||||
@@ -67,7 +67,7 @@ extension ConnectionsSettings {
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
|
||||
private func channelDetail(_ channel: ConnectionChannel) -> some View {
|
||||
private func channelDetail(_ channel: ChannelItem) -> some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.detailHeader(for: channel)
|
||||
@@ -81,7 +81,7 @@ extension ConnectionsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
private func sidebarRow(_ channel: ConnectionChannel) -> some View {
|
||||
private func sidebarRow(_ channel: ChannelItem) -> some View {
|
||||
let isSelected = self.selectedChannel == channel
|
||||
return Button {
|
||||
self.selectedChannel = channel
|
||||
@@ -119,7 +119,7 @@ extension ConnectionsSettings {
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
private func detailHeader(for channel: ConnectionChannel) -> some View {
|
||||
private func detailHeader(for channel: ChannelItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Label(channel.detailTitle, systemImage: channel.systemImage)
|
||||
19
apps/macos/Sources/Clawdbot/ChannelsSettings.swift
Normal file
19
apps/macos/Sources/Clawdbot/ChannelsSettings.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct ChannelsSettings: View {
|
||||
struct ChannelItem: Identifiable, Hashable {
|
||||
let id: String
|
||||
let title: String
|
||||
let detailTitle: String
|
||||
let systemImage: String
|
||||
let sortOrder: Int
|
||||
}
|
||||
|
||||
@Bindable var store: ChannelsStore
|
||||
@State var selectedChannel: ChannelItem?
|
||||
|
||||
init(store: ChannelsStore = .shared) {
|
||||
self.store = store
|
||||
}
|
||||
}
|
||||
154
apps/macos/Sources/Clawdbot/ChannelsStore+Config.swift
Normal file
154
apps/macos/Sources/Clawdbot/ChannelsStore+Config.swift
Normal file
@@ -0,0 +1,154 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
extension ChannelsStore {
|
||||
func loadConfigSchema() async {
|
||||
guard !self.configSchemaLoading else { return }
|
||||
self.configSchemaLoading = true
|
||||
defer { self.configSchemaLoading = false }
|
||||
|
||||
do {
|
||||
let res: ConfigSchemaResponse = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configSchema,
|
||||
params: nil,
|
||||
timeoutMs: 8000)
|
||||
let schemaValue = res.schema.foundationValue
|
||||
self.configSchema = ConfigSchemaNode(raw: schemaValue)
|
||||
let hintValues = res.uihints.mapValues { $0.foundationValue }
|
||||
self.configUiHints = decodeUiHints(hintValues)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() async {
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 10000)
|
||||
self.configStatus = snap.valid == false
|
||||
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
|
||||
: nil
|
||||
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
self.configDraft = cloneConfigValue(self.configRoot) as? [String: Any] ?? self.configRoot
|
||||
self.configDirty = false
|
||||
self.configLoaded = true
|
||||
|
||||
self.applyUIConfig(snap)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func applyUIConfig(_ snap: ConfigSnapshot) {
|
||||
let ui = snap.config?["ui"]?.dictionaryValue
|
||||
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||
}
|
||||
|
||||
func channelConfigSchema(for channelId: String) -> ConfigSchemaNode? {
|
||||
guard let root = self.configSchema else { return nil }
|
||||
return root.node(at: [.key("channels"), .key(channelId)])
|
||||
}
|
||||
|
||||
func configValue(at path: ConfigPath) -> Any? {
|
||||
if let value = valueAtPath(self.configDraft, path: path) {
|
||||
return value
|
||||
}
|
||||
guard path.count >= 2 else { return nil }
|
||||
if case .key("channels") = path[0], case .key(_) = path[1] {
|
||||
let fallbackPath = Array(path.dropFirst())
|
||||
return valueAtPath(self.configDraft, path: fallbackPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateConfigValue(path: ConfigPath, value: Any?) {
|
||||
var root = self.configDraft
|
||||
setValue(&root, path: path, value: value)
|
||||
self.configDraft = root
|
||||
self.configDirty = true
|
||||
}
|
||||
|
||||
func saveConfigDraft() async {
|
||||
guard !self.isSavingConfig else { return }
|
||||
self.isSavingConfig = true
|
||||
defer { self.isSavingConfig = false }
|
||||
|
||||
do {
|
||||
try await ConfigStore.save(self.configDraft)
|
||||
await self.loadConfig()
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func reloadConfigDraft() async {
|
||||
await self.loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
|
||||
var current: Any? = root
|
||||
for segment in path {
|
||||
switch segment {
|
||||
case .key(let key):
|
||||
guard let dict = current as? [String: Any] else { return nil }
|
||||
current = dict[key]
|
||||
case .index(let index):
|
||||
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
|
||||
current = array[index]
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
|
||||
guard let segment = path.first else { return }
|
||||
switch segment {
|
||||
case .key(let key):
|
||||
var dict = root as? [String: Any] ?? [:]
|
||||
if path.count == 1 {
|
||||
if let value {
|
||||
dict[key] = value
|
||||
} else {
|
||||
dict.removeValue(forKey: key)
|
||||
}
|
||||
root = dict
|
||||
return
|
||||
}
|
||||
var child = dict[key] ?? [:]
|
||||
setValue(&child, path: Array(path.dropFirst()), value: value)
|
||||
dict[key] = child
|
||||
root = dict
|
||||
case .index(let index):
|
||||
var array = root as? [Any] ?? []
|
||||
if index >= array.count {
|
||||
array.append(contentsOf: repeatElement(NSNull(), count: index - array.count + 1))
|
||||
}
|
||||
if path.count == 1 {
|
||||
if let value {
|
||||
array[index] = value
|
||||
} else if array.indices.contains(index) {
|
||||
array.remove(at: index)
|
||||
}
|
||||
root = array
|
||||
return
|
||||
}
|
||||
var child = array[index]
|
||||
setValue(&child, path: Array(path.dropFirst()), value: value)
|
||||
array[index] = child
|
||||
root = array
|
||||
}
|
||||
}
|
||||
|
||||
private func cloneConfigValue(_ value: Any) -> Any {
|
||||
guard JSONSerialization.isValidJSONObject(value) else { return value }
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: value, options: [])
|
||||
return try JSONSerialization.jsonObject(with: data, options: [])
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
extension ConnectionsStore {
|
||||
extension ChannelsStore {
|
||||
func start() {
|
||||
guard !self.isPreview else { return }
|
||||
guard self.pollTask == nil else { return }
|
||||
self.pollTask = Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refresh(probe: true)
|
||||
await self.loadConfigSchema()
|
||||
await self.loadConfig()
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
||||
@@ -187,49 +187,10 @@ struct ConfigSnapshot: Codable {
|
||||
let issues: [Issue]?
|
||||
}
|
||||
|
||||
struct DiscordGuildChannelForm: Identifiable {
|
||||
let id = UUID()
|
||||
var key: String
|
||||
var allow: Bool
|
||||
var requireMention: Bool
|
||||
|
||||
init(key: String = "", allow: Bool = true, requireMention: Bool = false) {
|
||||
self.key = key
|
||||
self.allow = allow
|
||||
self.requireMention = requireMention
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscordGuildForm: Identifiable {
|
||||
let id = UUID()
|
||||
var key: String
|
||||
var slug: String
|
||||
var requireMention: Bool
|
||||
var reactionNotifications: String
|
||||
var users: String
|
||||
var channels: [DiscordGuildChannelForm]
|
||||
|
||||
init(
|
||||
key: String = "",
|
||||
slug: String = "",
|
||||
requireMention: Bool = false,
|
||||
reactionNotifications: String = "own",
|
||||
users: String = "",
|
||||
channels: [DiscordGuildChannelForm] = [])
|
||||
{
|
||||
self.key = key
|
||||
self.slug = slug
|
||||
self.requireMention = requireMention
|
||||
self.reactionNotifications = reactionNotifications
|
||||
self.users = users
|
||||
self.channels = channels
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ConnectionsStore {
|
||||
static let shared = ConnectionsStore()
|
||||
final class ChannelsStore {
|
||||
static let shared = ChannelsStore()
|
||||
|
||||
var snapshot: ChannelsStatusSnapshot?
|
||||
var lastError: String?
|
||||
@@ -240,75 +201,21 @@ final class ConnectionsStore {
|
||||
var whatsappLoginQrDataUrl: String?
|
||||
var whatsappLoginConnected: Bool?
|
||||
var whatsappBusy = false
|
||||
|
||||
var telegramToken: String = ""
|
||||
var telegramRequireMention = true
|
||||
var telegramAllowFrom: String = ""
|
||||
var telegramProxy: String = ""
|
||||
var telegramWebhookUrl: String = ""
|
||||
var telegramWebhookSecret: String = ""
|
||||
var telegramWebhookPath: String = ""
|
||||
var telegramBusy = false
|
||||
var discordEnabled = true
|
||||
var discordToken: String = ""
|
||||
var discordDmEnabled = true
|
||||
var discordAllowFrom: String = ""
|
||||
var discordGroupEnabled = false
|
||||
var discordGroupChannels: String = ""
|
||||
var discordMediaMaxMb: String = ""
|
||||
var discordHistoryLimit: String = ""
|
||||
var discordTextChunkLimit: String = ""
|
||||
var discordReplyToMode: String = "off"
|
||||
var discordGuilds: [DiscordGuildForm] = []
|
||||
var discordActionReactions = true
|
||||
var discordActionStickers = true
|
||||
var discordActionPolls = true
|
||||
var discordActionPermissions = true
|
||||
var discordActionMessages = true
|
||||
var discordActionThreads = true
|
||||
var discordActionPins = true
|
||||
var discordActionSearch = true
|
||||
var discordActionMemberInfo = true
|
||||
var discordActionRoleInfo = true
|
||||
var discordActionChannelInfo = true
|
||||
var discordActionVoiceStatus = true
|
||||
var discordActionEvents = true
|
||||
var discordActionRoles = false
|
||||
var discordActionModeration = false
|
||||
var discordSlashEnabled = false
|
||||
var discordSlashName: String = ""
|
||||
var discordSlashSessionPrefix: String = ""
|
||||
var discordSlashEphemeral = true
|
||||
var signalEnabled = true
|
||||
var signalAccount: String = ""
|
||||
var signalHttpUrl: String = ""
|
||||
var signalHttpHost: String = ""
|
||||
var signalHttpPort: String = ""
|
||||
var signalCliPath: String = ""
|
||||
var signalAutoStart = true
|
||||
var signalReceiveMode: String = ""
|
||||
var signalIgnoreAttachments = false
|
||||
var signalIgnoreStories = false
|
||||
var signalSendReadReceipts = false
|
||||
var signalAllowFrom: String = ""
|
||||
var signalMediaMaxMb: String = ""
|
||||
var imessageEnabled = true
|
||||
var imessageCliPath: String = ""
|
||||
var imessageDbPath: String = ""
|
||||
var imessageService: String = "auto"
|
||||
var imessageRegion: String = ""
|
||||
var imessageAllowFrom: String = ""
|
||||
var imessageIncludeAttachments = false
|
||||
var imessageMediaMaxMb: String = ""
|
||||
|
||||
var configStatus: String?
|
||||
var isSavingConfig = false
|
||||
var configSchemaLoading = false
|
||||
var configSchema: ConfigSchemaNode?
|
||||
var configUiHints: [String: ConfigUiHint] = [:]
|
||||
var configDraft: [String: Any] = [:]
|
||||
var configDirty = false
|
||||
|
||||
let interval: TimeInterval = 45
|
||||
let isPreview: Bool
|
||||
var pollTask: Task<Void, Never>?
|
||||
var configRoot: [String: Any] = [:]
|
||||
var configLoaded = false
|
||||
var configHash: String?
|
||||
|
||||
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||
self.isPreview = isPreview
|
||||
172
apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift
Normal file
172
apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift
Normal file
@@ -0,0 +1,172 @@
|
||||
import Foundation
|
||||
|
||||
enum ConfigPathSegment: Hashable {
|
||||
case key(String)
|
||||
case index(Int)
|
||||
}
|
||||
|
||||
typealias ConfigPath = [ConfigPathSegment]
|
||||
|
||||
struct ConfigUiHint {
|
||||
let label: String?
|
||||
let help: String?
|
||||
let order: Double?
|
||||
let advanced: Bool?
|
||||
let sensitive: Bool?
|
||||
let placeholder: String?
|
||||
|
||||
init(raw: [String: Any]) {
|
||||
self.label = raw["label"] as? String
|
||||
self.help = raw["help"] as? String
|
||||
if let order = raw["order"] as? Double {
|
||||
self.order = order
|
||||
} else if let orderInt = raw["order"] as? Int {
|
||||
self.order = Double(orderInt)
|
||||
} else {
|
||||
self.order = nil
|
||||
}
|
||||
self.advanced = raw["advanced"] as? Bool
|
||||
self.sensitive = raw["sensitive"] as? Bool
|
||||
self.placeholder = raw["placeholder"] as? String
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigSchemaNode {
|
||||
let raw: [String: Any]
|
||||
|
||||
init?(raw: Any) {
|
||||
guard let dict = raw as? [String: Any] else { return nil }
|
||||
self.raw = dict
|
||||
}
|
||||
|
||||
var title: String? { self.raw["title"] as? String }
|
||||
var description: String? { self.raw["description"] as? String }
|
||||
var enumValues: [Any]? { self.raw["enum"] as? [Any] }
|
||||
var requiredKeys: Set<String> {
|
||||
Set((self.raw["required"] as? [String]) ?? [])
|
||||
}
|
||||
|
||||
var typeList: [String] {
|
||||
if let type = self.raw["type"] as? String { return [type] }
|
||||
if let types = self.raw["type"] as? [String] { return types }
|
||||
return []
|
||||
}
|
||||
|
||||
var schemaType: String? {
|
||||
let filtered = self.typeList.filter { $0 != "null" }
|
||||
if let first = filtered.first { return first }
|
||||
return self.typeList.first
|
||||
}
|
||||
|
||||
var properties: [String: ConfigSchemaNode] {
|
||||
guard let props = self.raw["properties"] as? [String: Any] else { return [:] }
|
||||
return props.compactMapValues { ConfigSchemaNode(raw: $0) }
|
||||
}
|
||||
|
||||
var items: ConfigSchemaNode? {
|
||||
if let items = self.raw["items"] as? [Any], let first = items.first {
|
||||
return ConfigSchemaNode(raw: first)
|
||||
}
|
||||
if let items = self.raw["items"] {
|
||||
return ConfigSchemaNode(raw: items)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var additionalProperties: ConfigSchemaNode? {
|
||||
if let additional = self.raw["additionalProperties"] as? [String: Any] {
|
||||
return ConfigSchemaNode(raw: additional)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var allowsAdditionalProperties: Bool {
|
||||
if let allow = self.raw["additionalProperties"] as? Bool { return allow }
|
||||
return self.additionalProperties != nil
|
||||
}
|
||||
|
||||
var defaultValue: Any {
|
||||
if let value = self.raw["default"] { return value }
|
||||
switch self.schemaType {
|
||||
case "object":
|
||||
return [String: Any]()
|
||||
case "array":
|
||||
return [Any]()
|
||||
case "boolean":
|
||||
return false
|
||||
case "integer":
|
||||
return 0
|
||||
case "number":
|
||||
return 0.0
|
||||
case "string":
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func node(at path: ConfigPath) -> ConfigSchemaNode? {
|
||||
var current: ConfigSchemaNode? = self
|
||||
for segment in path {
|
||||
guard let node = current else { return nil }
|
||||
switch segment {
|
||||
case .key(let key):
|
||||
if node.schemaType == "object" {
|
||||
if let next = node.properties[key] {
|
||||
current = next
|
||||
continue
|
||||
}
|
||||
if let additional = node.additionalProperties {
|
||||
current = additional
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
case .index:
|
||||
guard node.schemaType == "array" else { return nil }
|
||||
current = node.items
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
func decodeUiHints(_ raw: [String: Any]) -> [String: ConfigUiHint] {
|
||||
raw.reduce(into: [:]) { result, entry in
|
||||
if let hint = entry.value as? [String: Any] {
|
||||
result[entry.key] = ConfigUiHint(raw: hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiHint? {
|
||||
let key = pathKey(path)
|
||||
if let direct = hints[key] { return direct }
|
||||
let segments = key.split(separator: ".").map(String.init)
|
||||
for (hintKey, hint) in hints {
|
||||
guard hintKey.contains("*") else { continue }
|
||||
let hintSegments = hintKey.split(separator: ".").map(String.init)
|
||||
guard hintSegments.count == segments.count else { continue }
|
||||
var match = true
|
||||
for (index, seg) in segments.enumerated() {
|
||||
let hintSegment = hintSegments[index]
|
||||
if hintSegment != "*" && hintSegment != seg {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match { return hint }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pathKey(_ path: ConfigPath) -> String {
|
||||
path.compactMap { segment -> String? in
|
||||
switch segment {
|
||||
case .key(let key): return key
|
||||
case .index: return nil
|
||||
}
|
||||
}
|
||||
.joined(separator: ".")
|
||||
}
|
||||
@@ -4,86 +4,54 @@ import SwiftUI
|
||||
struct ConfigSettings: View {
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||
private let state = AppStateStore.shared
|
||||
private let labelColumnWidth: CGFloat = 120
|
||||
private static let browserAttachOnlyHelp =
|
||||
"When enabled, the browser server will only connect if the clawd browser is already running."
|
||||
private static let browserProfileNote =
|
||||
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
|
||||
+ "so it won’t interfere with your daily browser."
|
||||
@State private var configModel: String = ""
|
||||
@State private var configSaving = false
|
||||
@Bindable var store: ChannelsStore
|
||||
@State private var hasLoaded = false
|
||||
@State private var models: [ModelChoice] = []
|
||||
@State private var modelsLoading = false
|
||||
@State private var modelSearchQuery: String = ""
|
||||
@State private var isModelPickerOpen = false
|
||||
@State private var modelError: String?
|
||||
@State private var modelsSourceLabel: String?
|
||||
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
||||
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
||||
@State private var allowAutosave = false
|
||||
@State private var heartbeatMinutes: Int?
|
||||
@State private var heartbeatBody: String = "HEARTBEAT"
|
||||
|
||||
// clawd browser settings (stored in ~/.clawdbot/clawdbot.json under "browser")
|
||||
@State private var browserEnabled: Bool = true
|
||||
@State private var browserControlUrl: String = "http://127.0.0.1:18791"
|
||||
@State private var browserColorHex: String = "#FF4500"
|
||||
@State private var browserAttachOnly: Bool = false
|
||||
|
||||
// Talk mode settings (stored in ~/.clawdbot/clawdbot.json under "talk")
|
||||
@State private var talkVoiceId: String = ""
|
||||
@State private var talkInterruptOnSpeech: Bool = true
|
||||
@State private var talkApiKey: String = ""
|
||||
@State private var gatewayApiKeyFound = false
|
||||
@FocusState private var modelSearchFocused: Bool
|
||||
|
||||
private struct ConfigDraft {
|
||||
let configModel: String
|
||||
let heartbeatMinutes: Int?
|
||||
let heartbeatBody: String
|
||||
let browserEnabled: Bool
|
||||
let browserControlUrl: String
|
||||
let browserColorHex: String
|
||||
let browserAttachOnly: Bool
|
||||
let talkVoiceId: String
|
||||
let talkApiKey: String
|
||||
let talkInterruptOnSpeech: Bool
|
||||
init(store: ChannelsStore = .shared) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView { self.content }
|
||||
.onChange(of: self.modelCatalogPath) { _, _ in
|
||||
Task { await self.loadModels() }
|
||||
}
|
||||
.onChange(of: self.modelCatalogReloadBump) { _, _ in
|
||||
Task { await self.loadModels() }
|
||||
}
|
||||
.task {
|
||||
guard !self.hasLoaded else { return }
|
||||
guard !self.isPreview else { return }
|
||||
self.hasLoaded = true
|
||||
await self.loadConfig()
|
||||
await self.loadModels()
|
||||
await self.refreshGatewayTalkApiKey()
|
||||
self.allowAutosave = true
|
||||
}
|
||||
ScrollView {
|
||||
self.content
|
||||
}
|
||||
.task {
|
||||
guard !self.hasLoaded else { return }
|
||||
guard !self.isPreview else { return }
|
||||
self.hasLoaded = true
|
||||
await self.store.loadConfigSchema()
|
||||
await self.store.loadConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
self.agentSection
|
||||
.disabled(self.isNixMode)
|
||||
self.heartbeatSection
|
||||
.disabled(self.isNixMode)
|
||||
self.talkSection
|
||||
.disabled(self.isNixMode)
|
||||
self.browserSection
|
||||
.disabled(self.isNixMode)
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
self.actionRow
|
||||
Group {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let schema = self.store.configSchema {
|
||||
ConfigSchemaForm(store: self.store, schema: schema, path: [])
|
||||
.disabled(self.isNixMode)
|
||||
} else {
|
||||
Text("Schema unavailable.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if self.store.configDirty && !self.isNixMode {
|
||||
Text("Unsaved changes")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -94,843 +62,33 @@ extension ConfigSettings {
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
Text("Clawdbot CLI config")
|
||||
Text("Config")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text(self.isNixMode
|
||||
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
|
||||
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
|
||||
: "Edit ~/.clawdbot/clawdbot.json using the schema-driven form.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var agentSection: some View {
|
||||
GroupBox("Agent") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Model")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
self.modelPickerField
|
||||
self.modelMetaLabels
|
||||
}
|
||||
}
|
||||
private var actionRow: some View {
|
||||
HStack(spacing: 10) {
|
||||
Button("Reload") {
|
||||
Task { await self.store.reloadConfigDraft() }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.disabled(!self.store.configLoaded)
|
||||
|
||||
private var modelPickerField: some View {
|
||||
Button {
|
||||
guard !self.modelsLoading else { return }
|
||||
self.isModelPickerOpen = true
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Text(self.modelPickerLabel)
|
||||
.foregroundStyle(self.modelPickerLabelIsPlaceholder ? .secondary : .primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.foregroundStyle(.secondary)
|
||||
Button(self.store.isSavingConfig ? "Saving…" : "Save") {
|
||||
Task { await self.store.saveConfigDraft() }
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configDirty)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(
|
||||
Color(nsColor: .textBackgroundColor)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(
|
||||
Color.secondary.opacity(0.25),
|
||||
lineWidth: 1))
|
||||
.popover(isPresented: self.$isModelPickerOpen, arrowEdge: .bottom) {
|
||||
self.modelPickerPopover
|
||||
}
|
||||
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
|
||||
.onChange(of: self.isModelPickerOpen) { _, isOpen in
|
||||
if isOpen {
|
||||
self.modelSearchQuery = ""
|
||||
self.modelSearchFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var modelPickerPopover: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
TextField("Search models", text: self.$modelSearchQuery)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused(self.$modelSearchFocused)
|
||||
.controlSize(.small)
|
||||
.onSubmit {
|
||||
if let exact = self.exactMatchForQuery() {
|
||||
self.selectModel(exact)
|
||||
return
|
||||
}
|
||||
if let manual = self.manualEntryCandidate {
|
||||
self.selectManualModel(manual)
|
||||
return
|
||||
}
|
||||
if self.modelSearchMatches.count == 1 {
|
||||
self.selectModel(self.modelSearchMatches[0])
|
||||
}
|
||||
}
|
||||
List {
|
||||
if self.modelSearchMatches.isEmpty {
|
||||
Text("No models match \"\(self.modelSearchQuery)\"")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.modelSearchMatches) { choice in
|
||||
Button {
|
||||
self.selectModel(choice)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Text(choice.name)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text(choice.provider.uppercased())
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 6)
|
||||
.background(Color.secondary.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
}
|
||||
}
|
||||
|
||||
if let manual = self.manualEntryCandidate {
|
||||
Button("Use \"\(manual)\"") {
|
||||
self.selectManualModel(manual)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
.frame(width: 340, height: 260)
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var modelMetaLabels: some View {
|
||||
if self.shouldShowProviderHintForSelection {
|
||||
self.statusLine(label: "Tip: prefer provider/model (e.g. openai-codex/gpt-5.2)", color: .orange)
|
||||
}
|
||||
|
||||
if let contextLabel = self.selectedContextLabel {
|
||||
Text(contextLabel)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let authMode = self.selectedAnthropicAuthMode {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(authMode.isConfigured ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
Text("Anthropic auth: \(authMode.shortLabel)")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(authMode.isConfigured ? Color.secondary : Color.orange)
|
||||
.help(self.anthropicAuthHelpText)
|
||||
|
||||
AnthropicAuthControls(connectionMode: self.state.connectionMode)
|
||||
}
|
||||
|
||||
if let modelError {
|
||||
Text(modelError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let modelsSourceLabel {
|
||||
Text("Model catalog: \(modelsSourceLabel)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var anthropicAuthHelpText: String {
|
||||
"Determined from Clawdbot OAuth token file (~/.clawdbot/credentials/oauth.json) " +
|
||||
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
|
||||
}
|
||||
|
||||
private var heartbeatSection: some View {
|
||||
GroupBox("Heartbeat") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Schedule")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 12) {
|
||||
Stepper(
|
||||
value: Binding(
|
||||
get: { self.heartbeatMinutes ?? 10 },
|
||||
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
|
||||
in: 0...720)
|
||||
{
|
||||
Text("Every \(self.heartbeatMinutes ?? 10) min")
|
||||
.frame(width: 150, alignment: .leading)
|
||||
}
|
||||
.help("Set to 0 to disable automatic heartbeats")
|
||||
|
||||
TextField("HEARTBEAT", text: self.$heartbeatBody)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.heartbeatBody) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
.help("Message body sent on each heartbeat")
|
||||
}
|
||||
Text("Heartbeats keep agent sessions warm; 0 minutes disables them.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var browserSection: some View {
|
||||
GroupBox("Browser (clawd)") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$browserEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Control URL")
|
||||
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Browser path")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let label = self.browserPathLabel {
|
||||
Text(label)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
} else {
|
||||
Text("—")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Accent")
|
||||
HStack(spacing: 8) {
|
||||
TextField("#FF4500", text: self.$browserColorHex)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 120)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
|
||||
Circle()
|
||||
.fill(self.browserColor)
|
||||
.frame(width: 12, height: 12)
|
||||
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
|
||||
Text("lobster-orange")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Attach only")
|
||||
Toggle("", isOn: self.$browserAttachOnly)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
|
||||
.help(Self.browserAttachOnlyHelp)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(Self.browserProfileNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var talkSection: some View {
|
||||
GroupBox("Talk Mode") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Voice ID")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("ElevenLabs voice ID", text: self.$talkVoiceId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.talkVoiceId) { _, _ in self.autosaveConfig() }
|
||||
if !self.talkVoiceSuggestions.isEmpty {
|
||||
Menu {
|
||||
ForEach(self.talkVoiceSuggestions, id: \.self) { value in
|
||||
Button(value) {
|
||||
self.talkVoiceId = value
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Suggestions", systemImage: "chevron.up.chevron.down")
|
||||
}
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
Text("Defaults to ELEVENLABS_VOICE_ID / SAG_VOICE_ID if unset.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("API key")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
SecureField("ELEVENLABS_API_KEY", text: self.$talkApiKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(self.hasEnvApiKey)
|
||||
.onChange(of: self.talkApiKey) { _, _ in self.autosaveConfig() }
|
||||
if !self.hasEnvApiKey, !self.talkApiKey.isEmpty {
|
||||
Button("Clear") {
|
||||
self.talkApiKey = ""
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
self.statusLine(label: self.apiKeyStatusLabel, color: self.apiKeyStatusColor)
|
||||
if self.hasEnvApiKey {
|
||||
Text("Using ELEVENLABS_API_KEY from the environment.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if self.gatewayApiKeyFound,
|
||||
self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
Text("Using API key from the gateway profile.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Interrupt")
|
||||
Toggle("Stop speaking when you start talking", isOn: self.$talkInterruptOnSpeech)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.onChange(of: self.talkInterruptOnSpeech) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func gridLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
private func statusLine(label: String, color: Color) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(label)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private func loadConfig() async {
|
||||
let parsed = await ConfigStore.load()
|
||||
let agents = parsed["agents"] as? [String: Any]
|
||||
let defaults = agents?["defaults"] as? [String: Any]
|
||||
let heartbeat = defaults?["heartbeat"] as? [String: Any]
|
||||
let heartbeatEvery = heartbeat?["every"] as? String
|
||||
let heartbeatBody = heartbeat?["prompt"] as? String
|
||||
let browser = parsed["browser"] as? [String: Any]
|
||||
let talk = parsed["talk"] as? [String: Any]
|
||||
|
||||
let loadedModel: String = {
|
||||
if let raw = defaults?["model"] as? String { return raw }
|
||||
if let modelDict = defaults?["model"] as? [String: Any],
|
||||
let primary = modelDict["primary"] as? String { return primary }
|
||||
return ""
|
||||
}()
|
||||
if !loadedModel.isEmpty {
|
||||
self.configModel = loadedModel
|
||||
} else {
|
||||
self.configModel = SessionLoader.fallbackModel
|
||||
}
|
||||
|
||||
if let heartbeatEvery {
|
||||
let digits = heartbeatEvery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.prefix { $0.isNumber }
|
||||
if let minutes = Int(digits) {
|
||||
self.heartbeatMinutes = minutes
|
||||
}
|
||||
}
|
||||
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
|
||||
|
||||
if let browser {
|
||||
if let enabled = browser["enabled"] as? Bool { self.browserEnabled = enabled }
|
||||
if let url = browser["controlUrl"] as? String, !url.isEmpty { self.browserControlUrl = url }
|
||||
if let color = browser["color"] as? String, !color.isEmpty { self.browserColorHex = color }
|
||||
if let attachOnly = browser["attachOnly"] as? Bool { self.browserAttachOnly = attachOnly }
|
||||
}
|
||||
|
||||
if let talk {
|
||||
if let voice = talk["voiceId"] as? String { self.talkVoiceId = voice }
|
||||
if let apiKey = talk["apiKey"] as? String { self.talkApiKey = apiKey }
|
||||
if let interrupt = talk["interruptOnSpeech"] as? Bool {
|
||||
self.talkInterruptOnSpeech = interrupt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshGatewayTalkApiKey() async {
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 8000)
|
||||
let talk = snap.config?["talk"]?.dictionaryValue
|
||||
let apiKey = talk?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.gatewayApiKeyFound = !(apiKey ?? "").isEmpty
|
||||
} catch {
|
||||
self.gatewayApiKeyFound = false
|
||||
}
|
||||
}
|
||||
|
||||
private func autosaveConfig() {
|
||||
guard self.allowAutosave, !self.isNixMode else { return }
|
||||
Task { await self.saveConfig() }
|
||||
}
|
||||
|
||||
private func saveConfig() async {
|
||||
guard !self.configSaving else { return }
|
||||
self.configSaving = true
|
||||
defer { self.configSaving = false }
|
||||
|
||||
let configModel = self.configModel
|
||||
let heartbeatMinutes = self.heartbeatMinutes
|
||||
let heartbeatBody = self.heartbeatBody
|
||||
let browserEnabled = self.browserEnabled
|
||||
let browserControlUrl = self.browserControlUrl
|
||||
let browserColorHex = self.browserColorHex
|
||||
let browserAttachOnly = self.browserAttachOnly
|
||||
let talkVoiceId = self.talkVoiceId
|
||||
let talkApiKey = self.talkApiKey
|
||||
let talkInterruptOnSpeech = self.talkInterruptOnSpeech
|
||||
|
||||
let draft = ConfigDraft(
|
||||
configModel: configModel,
|
||||
heartbeatMinutes: heartbeatMinutes,
|
||||
heartbeatBody: heartbeatBody,
|
||||
browserEnabled: browserEnabled,
|
||||
browserControlUrl: browserControlUrl,
|
||||
browserColorHex: browserColorHex,
|
||||
browserAttachOnly: browserAttachOnly,
|
||||
talkVoiceId: talkVoiceId,
|
||||
talkApiKey: talkApiKey,
|
||||
talkInterruptOnSpeech: talkInterruptOnSpeech)
|
||||
|
||||
let errorMessage = await ConfigSettings.buildAndSaveConfig(draft)
|
||||
|
||||
if let errorMessage {
|
||||
self.modelError = errorMessage
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? {
|
||||
var root = await ConfigStore.load()
|
||||
var agents = root["agents"] as? [String: Any] ?? [:]
|
||||
var defaults = agents["defaults"] as? [String: Any] ?? [:]
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
var talk = root["talk"] as? [String: Any] ?? [:]
|
||||
|
||||
let chosenModel = draft.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedModel = chosenModel
|
||||
if !trimmedModel.isEmpty {
|
||||
var model = defaults["model"] as? [String: Any] ?? [:]
|
||||
model["primary"] = trimmedModel
|
||||
defaults["model"] = model
|
||||
|
||||
var models = defaults["models"] as? [String: Any] ?? [:]
|
||||
if models[trimmedModel] == nil {
|
||||
models[trimmedModel] = [:]
|
||||
}
|
||||
defaults["models"] = models
|
||||
}
|
||||
|
||||
if let heartbeatMinutes = draft.heartbeatMinutes {
|
||||
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
|
||||
heartbeat["every"] = "\(heartbeatMinutes)m"
|
||||
defaults["heartbeat"] = heartbeat
|
||||
}
|
||||
|
||||
let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedBody.isEmpty {
|
||||
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
|
||||
heartbeat["prompt"] = trimmedBody
|
||||
defaults["heartbeat"] = heartbeat
|
||||
}
|
||||
|
||||
if defaults.isEmpty {
|
||||
agents.removeValue(forKey: "defaults")
|
||||
} else {
|
||||
agents["defaults"] = defaults
|
||||
}
|
||||
if agents.isEmpty {
|
||||
root.removeValue(forKey: "agents")
|
||||
} else {
|
||||
root["agents"] = agents
|
||||
}
|
||||
|
||||
browser["enabled"] = draft.browserEnabled
|
||||
let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl }
|
||||
let trimmedColor = draft.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedColor.isEmpty { browser["color"] = trimmedColor }
|
||||
browser["attachOnly"] = draft.browserAttachOnly
|
||||
root["browser"] = browser
|
||||
|
||||
let trimmedVoice = draft.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedVoice.isEmpty {
|
||||
talk.removeValue(forKey: "voiceId")
|
||||
} else {
|
||||
talk["voiceId"] = trimmedVoice
|
||||
}
|
||||
let trimmedApiKey = draft.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedApiKey.isEmpty {
|
||||
talk.removeValue(forKey: "apiKey")
|
||||
} else {
|
||||
talk["apiKey"] = trimmedApiKey
|
||||
}
|
||||
talk["interruptOnSpeech"] = draft.talkInterruptOnSpeech
|
||||
root["talk"] = talk
|
||||
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
return nil
|
||||
} catch {
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private var browserColor: Color {
|
||||
let raw = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let hex = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw
|
||||
guard hex.count == 6, let value = Int(hex, radix: 16) else { return .orange }
|
||||
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||
let b = Double(value & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
|
||||
private var talkVoiceSuggestions: [String] {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
let candidates = [
|
||||
self.talkVoiceId,
|
||||
env["ELEVENLABS_VOICE_ID"] ?? "",
|
||||
env["SAG_VOICE_ID"] ?? "",
|
||||
]
|
||||
var seen = Set<String>()
|
||||
return candidates
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
private var hasEnvApiKey: Bool {
|
||||
let raw = ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] ?? ""
|
||||
return !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private var apiKeyStatusLabel: String {
|
||||
if self.hasEnvApiKey { return "ElevenLabs API key: found (environment)" }
|
||||
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return "ElevenLabs API key: stored in config"
|
||||
}
|
||||
if self.gatewayApiKeyFound { return "ElevenLabs API key: found (gateway)" }
|
||||
return "ElevenLabs API key: missing"
|
||||
}
|
||||
|
||||
private var apiKeyStatusColor: Color {
|
||||
if self.hasEnvApiKey { return .green }
|
||||
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return .green }
|
||||
if self.gatewayApiKeyFound { return .green }
|
||||
return .red
|
||||
}
|
||||
|
||||
private var browserPathLabel: String? {
|
||||
guard self.browserEnabled else { return nil }
|
||||
|
||||
let host = (URL(string: self.browserControlUrl)?.host ?? "").lowercased()
|
||||
if !host.isEmpty, !Self.isLoopbackHost(host) {
|
||||
return "remote (\(host))"
|
||||
}
|
||||
|
||||
guard let candidate = Self.detectedBrowserCandidate() else { return nil }
|
||||
return candidate.executablePath ?? candidate.appPath
|
||||
}
|
||||
|
||||
private struct BrowserCandidate {
|
||||
let name: String
|
||||
let appPath: String
|
||||
let executablePath: String?
|
||||
}
|
||||
|
||||
private static func detectedBrowserCandidate() -> BrowserCandidate? {
|
||||
let candidates: [(name: String, appName: String)] = [
|
||||
("Google Chrome Canary", "Google Chrome Canary.app"),
|
||||
("Chromium", "Chromium.app"),
|
||||
("Google Chrome", "Google Chrome.app"),
|
||||
]
|
||||
|
||||
let roots = [
|
||||
"/Applications",
|
||||
"\(NSHomeDirectory())/Applications",
|
||||
]
|
||||
|
||||
let fm = FileManager.default
|
||||
for (name, appName) in candidates {
|
||||
for root in roots {
|
||||
let appPath = "\(root)/\(appName)"
|
||||
if fm.fileExists(atPath: appPath) {
|
||||
let bundle = Bundle(url: URL(fileURLWithPath: appPath))
|
||||
let exec = bundle?.executableURL?.path
|
||||
return BrowserCandidate(name: name, appPath: appPath, executablePath: exec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isLoopbackHost(_ host: String) -> Bool {
|
||||
if host == "localhost" { return true }
|
||||
if host == "127.0.0.1" { return true }
|
||||
if host == "::1" { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private func loadModels() async {
|
||||
guard !self.modelsLoading else { return }
|
||||
self.modelsLoading = true
|
||||
self.modelError = nil
|
||||
self.modelsSourceLabel = nil
|
||||
do {
|
||||
let res: ModelsListResult =
|
||||
try await GatewayConnection.shared
|
||||
.requestDecoded(
|
||||
method: .modelsList,
|
||||
timeoutMs: 15000)
|
||||
self.models = res.models
|
||||
self.modelsSourceLabel = "gateway"
|
||||
} catch {
|
||||
do {
|
||||
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
|
||||
self.models = loaded
|
||||
self.modelsSourceLabel = "local fallback"
|
||||
} catch {
|
||||
self.modelError = error.localizedDescription
|
||||
self.models = []
|
||||
}
|
||||
}
|
||||
self.modelsLoading = false
|
||||
}
|
||||
|
||||
private struct ModelsListResult: Decodable {
|
||||
let models: [ModelChoice]
|
||||
}
|
||||
|
||||
private var modelSearchMatches: [ModelChoice] {
|
||||
let raw = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !raw.isEmpty else { return self.models }
|
||||
let tokens = raw
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { token in
|
||||
token.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
||||
}
|
||||
.filter { !$0.isEmpty }
|
||||
guard !tokens.isEmpty else { return self.models }
|
||||
return self.models.filter { choice in
|
||||
let haystack = [
|
||||
choice.id,
|
||||
choice.name,
|
||||
choice.provider,
|
||||
self.modelRef(for: choice),
|
||||
]
|
||||
.joined(separator: " ")
|
||||
.lowercased()
|
||||
return tokens.allSatisfy { haystack.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedModelChoice: ModelChoice? {
|
||||
guard !self.configModel.isEmpty else { return nil }
|
||||
return self.models.first(where: { self.matchesConfigModel($0) })
|
||||
}
|
||||
|
||||
private var modelPickerLabel: String {
|
||||
if let choice = self.selectedModelChoice {
|
||||
return "\(choice.name) — \(choice.provider.uppercased())"
|
||||
}
|
||||
if !self.configModel.isEmpty { return self.configModel }
|
||||
return "Select model"
|
||||
}
|
||||
|
||||
private var modelPickerLabelIsPlaceholder: Bool {
|
||||
self.configModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private var manualEntryCandidate: String? {
|
||||
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
guard !self.isKnownModelRef(cleaned) else { return nil }
|
||||
return cleaned
|
||||
}
|
||||
|
||||
private func isKnownModelRef(_ value: String) -> Bool {
|
||||
let needle = value.lowercased()
|
||||
return self.models.contains { choice in
|
||||
choice.id.lowercased() == needle
|
||||
|| self.modelRef(for: choice).lowercased() == needle
|
||||
}
|
||||
}
|
||||
|
||||
private func modelRef(for choice: ModelChoice) -> String {
|
||||
let id = choice.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let provider = choice.provider.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !provider.isEmpty else { return id }
|
||||
let normalizedProvider = provider.lowercased()
|
||||
if id.lowercased().hasPrefix("\(normalizedProvider)/") {
|
||||
return id
|
||||
}
|
||||
return "\(normalizedProvider)/\(id)"
|
||||
}
|
||||
|
||||
private func matchesConfigModel(_ choice: ModelChoice) -> Bool {
|
||||
let configured = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !configured.isEmpty else { return false }
|
||||
if configured.caseInsensitiveCompare(choice.id) == .orderedSame { return true }
|
||||
let ref = self.modelRef(for: choice)
|
||||
return configured.caseInsensitiveCompare(ref) == .orderedSame
|
||||
}
|
||||
|
||||
private func exactMatchForQuery() -> ModelChoice? {
|
||||
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%")).lowercased()
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
return self.models.first(where: { choice in
|
||||
let id = choice.id.lowercased()
|
||||
if id == cleaned { return true }
|
||||
return self.modelRef(for: choice).lowercased() == cleaned
|
||||
})
|
||||
}
|
||||
|
||||
private var shouldShowProviderHint: Bool {
|
||||
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
||||
return !cleaned.contains("/")
|
||||
}
|
||||
|
||||
private var shouldShowProviderHintForSelection: Bool {
|
||||
let trimmed = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
return !trimmed.contains("/")
|
||||
}
|
||||
|
||||
private func selectModel(_ choice: ModelChoice) {
|
||||
self.configModel = self.modelRef(for: choice)
|
||||
self.autosaveConfig()
|
||||
self.isModelPickerOpen = false
|
||||
}
|
||||
|
||||
private func selectManualModel(_ value: String) {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let slash = trimmed.firstIndex(of: "/") {
|
||||
let provider = trimmed[..<slash].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let model = trimmed[trimmed.index(after: slash)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.configModel = provider.isEmpty ? String(model) : "\(provider)/\(model)"
|
||||
} else {
|
||||
self.configModel = trimmed
|
||||
}
|
||||
self.autosaveConfig()
|
||||
self.isModelPickerOpen = false
|
||||
}
|
||||
|
||||
private var selectedContextLabel: String? {
|
||||
guard
|
||||
let choice = self.selectedModelChoice,
|
||||
let context = choice.contextWindow
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let human = context >= 1000 ? "\(context / 1000)k" : "\(context)"
|
||||
return "Context window: \(human) tokens"
|
||||
}
|
||||
|
||||
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
|
||||
guard let choice = self.selectedModelChoice else { return nil }
|
||||
guard choice.provider.lowercased() == "anthropic" else { return nil }
|
||||
return AnthropicAuthResolver.resolve()
|
||||
}
|
||||
|
||||
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
configuration.label
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
configuration.content
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct ConfigSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ConfigSettings()
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,707 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension ConnectionsSettings {
|
||||
func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
||||
GroupBox(title) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func channelHeaderActions(_ channel: ConnectionChannel) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
if channel == .whatsapp {
|
||||
Button("Logout") {
|
||||
Task { await self.store.logoutWhatsApp() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.whatsappBusy)
|
||||
}
|
||||
|
||||
if channel == .telegram {
|
||||
Button("Logout") {
|
||||
Task { await self.store.logoutTelegram() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.telegramBusy)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await self.store.refresh(probe: true) }
|
||||
} label: {
|
||||
if self.store.isRefreshing {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Refresh")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.isRefreshing)
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
var whatsAppSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.formSection("Linking") {
|
||||
if let message = self.store.whatsappLoginMessage {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.interpolation(.none)
|
||||
.frame(width: 180, height: 180)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.store.startWhatsAppLogin(force: false) }
|
||||
} label: {
|
||||
if self.store.whatsappBusy {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Show QR")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.store.whatsappBusy)
|
||||
|
||||
Button("Relink") {
|
||||
Task { await self.store.startWhatsAppLogin(force: true) }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.whatsappBusy)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var telegramSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.formSection("Authentication") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Bot token")
|
||||
if self.showTelegramToken {
|
||||
TextField("123:abc", text: self.$store.telegramToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.isTelegramTokenLocked)
|
||||
} else {
|
||||
SecureField("123:abc", text: self.$store.telegramToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.isTelegramTokenLocked)
|
||||
}
|
||||
Toggle("Show", isOn: self.$showTelegramToken)
|
||||
.toggleStyle(.switch)
|
||||
.disabled(self.isTelegramTokenLocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Access") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Require mention")
|
||||
Toggle("", isOn: self.$store.telegramRequireMention)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Allow from")
|
||||
TextField("123456789, @team", text: self.$store.telegramAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Webhook") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Webhook URL")
|
||||
TextField("https://example.com/telegram-webhook", text: self.$store.telegramWebhookUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Webhook secret")
|
||||
TextField("secret", text: self.$store.telegramWebhookSecret)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Webhook path")
|
||||
TextField("/telegram-webhook", text: self.$store.telegramWebhookPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Network") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Proxy")
|
||||
TextField("socks5://localhost:9050", text: self.$store.telegramProxy)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.isTelegramTokenLocked {
|
||||
Text("Token set via TELEGRAM_BOT_TOKEN env; config edits won’t override it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
self.configStatusMessage
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.store.saveTelegramConfig() }
|
||||
} label: {
|
||||
if self.store.isSavingConfig {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.store.isSavingConfig)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
var discordSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.formSection("Authentication") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$store.discordEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Bot token")
|
||||
if self.showDiscordToken {
|
||||
TextField("bot token", text: self.$store.discordToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.isDiscordTokenLocked)
|
||||
} else {
|
||||
SecureField("bot token", text: self.$store.discordToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.isDiscordTokenLocked)
|
||||
}
|
||||
Toggle("Show", isOn: self.$showDiscordToken)
|
||||
.toggleStyle(.switch)
|
||||
.disabled(self.isDiscordTokenLocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Messages") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Allow DMs from")
|
||||
TextField("123456789, username#1234", text: self.$store.discordAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("DMs enabled")
|
||||
Toggle("", isOn: self.$store.discordDmEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Group DMs")
|
||||
Toggle("", isOn: self.$store.discordGroupEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Group channels")
|
||||
TextField("channelId1, channelId2", text: self.$store.discordGroupChannels)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Reply to mode")
|
||||
Picker("", selection: self.$store.discordReplyToMode) {
|
||||
Text("off").tag("off")
|
||||
Text("first").tag("first")
|
||||
Text("all").tag("all")
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Limits") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Media max MB")
|
||||
TextField("8", text: self.$store.discordMediaMaxMb)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("History limit")
|
||||
TextField("20", text: self.$store.discordHistoryLimit)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Text chunk limit")
|
||||
TextField("2000", text: self.$store.discordTextChunkLimit)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Slash command") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$store.discordSlashEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Slash name")
|
||||
TextField("clawd", text: self.$store.discordSlashName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Session prefix")
|
||||
TextField("discord:slash", text: self.$store.discordSlashSessionPrefix)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Ephemeral")
|
||||
Toggle("", isOn: self.$store.discordSlashEphemeral)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GroupBox("Guilds") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(self.$store.discordGuilds) { $guild in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
TextField("guild id or slug", text: $guild.key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Remove") {
|
||||
self.store.discordGuilds.removeAll { $0.id == guild.id }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Slug")
|
||||
TextField("optional slug", text: $guild.slug)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Require mention")
|
||||
Toggle("", isOn: $guild.requireMention)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Reaction notifications")
|
||||
Picker("", selection: $guild.reactionNotifications) {
|
||||
Text("Off").tag("off")
|
||||
Text("Own").tag("own")
|
||||
Text("All").tag("all")
|
||||
Text("Allowlist").tag("allowlist")
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Users allowlist")
|
||||
TextField("123456789, username#1234", text: $guild.users)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Channels")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach($guild.channels) { $channel in
|
||||
HStack(spacing: 10) {
|
||||
TextField("channel id or slug", text: $channel.key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Allow", isOn: $channel.allow)
|
||||
.toggleStyle(.checkbox)
|
||||
Toggle("Require mention", isOn: $channel.requireMention)
|
||||
.toggleStyle(.checkbox)
|
||||
Button("Remove") {
|
||||
guild.channels.removeAll { $0.id == channel.id }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
Button("Add channel") {
|
||||
guild.channels.append(DiscordGuildChannelForm())
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
Button("Add guild") {
|
||||
self.store.discordGuilds.append(DiscordGuildForm())
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
GroupBox("Tool actions") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Reactions")
|
||||
Toggle("", isOn: self.$store.discordActionReactions)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Stickers")
|
||||
Toggle("", isOn: self.$store.discordActionStickers)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Polls")
|
||||
Toggle("", isOn: self.$store.discordActionPolls)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Permissions")
|
||||
Toggle("", isOn: self.$store.discordActionPermissions)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Messages")
|
||||
Toggle("", isOn: self.$store.discordActionMessages)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Threads")
|
||||
Toggle("", isOn: self.$store.discordActionThreads)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Pins")
|
||||
Toggle("", isOn: self.$store.discordActionPins)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Search")
|
||||
Toggle("", isOn: self.$store.discordActionSearch)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Member info")
|
||||
Toggle("", isOn: self.$store.discordActionMemberInfo)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Role info")
|
||||
Toggle("", isOn: self.$store.discordActionRoleInfo)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Channel info")
|
||||
Toggle("", isOn: self.$store.discordActionChannelInfo)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Voice status")
|
||||
Toggle("", isOn: self.$store.discordActionVoiceStatus)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Events")
|
||||
Toggle("", isOn: self.$store.discordActionEvents)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Role changes")
|
||||
Toggle("", isOn: self.$store.discordActionRoles)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Moderation")
|
||||
Toggle("", isOn: self.$store.discordActionModeration)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if self.isDiscordTokenLocked {
|
||||
Text("Token set via DISCORD_BOT_TOKEN env; config edits won’t override it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
self.configStatusMessage
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.store.saveDiscordConfig() }
|
||||
} label: {
|
||||
if self.store.isSavingConfig {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.store.isSavingConfig)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
var signalSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.formSection("Connection") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Behavior") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Access & limits") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.configStatusMessage
|
||||
|
||||
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()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
var imessageSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.formSection("Connection") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Behavior") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.configStatusMessage
|
||||
|
||||
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()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var configStatusMessage: some View {
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
func gridLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: 140, alignment: .leading)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectionsSettings: View {
|
||||
enum ConnectionChannel: String, CaseIterable, Identifiable, Hashable {
|
||||
case whatsapp
|
||||
case telegram
|
||||
case discord
|
||||
case signal
|
||||
case imessage
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var sortOrder: Int {
|
||||
switch self {
|
||||
case .whatsapp: 0
|
||||
case .telegram: 1
|
||||
case .discord: 2
|
||||
case .signal: 3
|
||||
case .imessage: 4
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .whatsapp: "WhatsApp"
|
||||
case .telegram: "Telegram"
|
||||
case .discord: "Discord"
|
||||
case .signal: "Signal"
|
||||
case .imessage: "iMessage"
|
||||
}
|
||||
}
|
||||
|
||||
var detailTitle: String {
|
||||
switch self {
|
||||
case .whatsapp: "WhatsApp Web"
|
||||
case .telegram: "Telegram Bot"
|
||||
case .discord: "Discord Bot"
|
||||
case .signal: "Signal REST"
|
||||
case .imessage: "iMessage (imsg)"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .whatsapp: "message"
|
||||
case .telegram: "paperplane"
|
||||
case .discord: "bubble.left.and.bubble.right"
|
||||
case .signal: "antenna.radiowaves.left.and.right"
|
||||
case .imessage: "message.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Bindable var store: ConnectionsStore
|
||||
@State var selectedChannel: ConnectionChannel?
|
||||
@State var showTelegramToken = false
|
||||
@State var showDiscordToken = false
|
||||
|
||||
init(store: ConnectionsStore = .shared) {
|
||||
self.store = store
|
||||
}
|
||||
}
|
||||
@@ -1,594 +0,0 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
extension ConnectionsStore {
|
||||
var isTelegramTokenLocked: Bool {
|
||||
self.snapshot?.decodeChannel("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
|
||||
.tokenSource == "env"
|
||||
}
|
||||
|
||||
var isDiscordTokenLocked: Bool {
|
||||
self.snapshot?.decodeChannel("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||
.tokenSource == "env"
|
||||
}
|
||||
|
||||
func loadConfig() async {
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 10000)
|
||||
self.configStatus = snap.valid == false
|
||||
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
|
||||
: nil
|
||||
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
self.configHash = snap.hash
|
||||
self.configLoaded = true
|
||||
|
||||
self.applyUIConfig(snap)
|
||||
self.applyTelegramConfig(snap)
|
||||
self.applyDiscordConfig(snap)
|
||||
self.applySignalConfig(snap)
|
||||
self.applyIMessageConfig(snap)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func applyUIConfig(_ snap: ConfigSnapshot) {
|
||||
let ui = snap.config?[
|
||||
"ui",
|
||||
]?.dictionaryValue
|
||||
let rawSeam = ui?[
|
||||
"seamColor",
|
||||
]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||
}
|
||||
|
||||
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
|
||||
if let channels = snap.config?["channels"]?.dictionaryValue,
|
||||
let entry = channels[key]?.dictionaryValue
|
||||
{
|
||||
return entry
|
||||
}
|
||||
return snap.config?[key]?.dictionaryValue
|
||||
}
|
||||
|
||||
private func applyTelegramConfig(_ snap: ConfigSnapshot) {
|
||||
let telegram = self.resolveChannelConfig(snap, key: "telegram")
|
||||
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
|
||||
let groups = telegram?["groups"]?.dictionaryValue
|
||||
let defaultGroup = groups?["*"]?.dictionaryValue
|
||||
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
|
||||
?? telegram?["requireMention"]?.boolValue
|
||||
?? true
|
||||
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
|
||||
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
|
||||
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
|
||||
self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? ""
|
||||
self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? ""
|
||||
}
|
||||
|
||||
private func applyDiscordConfig(_ snap: ConfigSnapshot) {
|
||||
let discord = self.resolveChannelConfig(snap, key: "discord")
|
||||
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
|
||||
self.discordToken = discord?["token"]?.stringValue ?? ""
|
||||
|
||||
let discordDm = discord?["dm"]?.dictionaryValue
|
||||
self.discordDmEnabled = discordDm?["enabled"]?.boolValue ?? true
|
||||
self.discordAllowFrom = self.stringList(from: discordDm?["allowFrom"]?.arrayValue)
|
||||
self.discordGroupEnabled = discordDm?["groupEnabled"]?.boolValue ?? false
|
||||
self.discordGroupChannels = self.stringList(from: discordDm?["groupChannels"]?.arrayValue)
|
||||
self.discordMediaMaxMb = self.numberString(from: discord?["mediaMaxMb"])
|
||||
self.discordHistoryLimit = self.numberString(from: discord?["historyLimit"])
|
||||
self.discordTextChunkLimit = self.numberString(from: discord?["textChunkLimit"])
|
||||
self.discordReplyToMode = self.replyMode(from: discord?["replyToMode"]?.stringValue)
|
||||
self.discordGuilds = self.decodeDiscordGuilds(discord?["guilds"]?.dictionaryValue)
|
||||
|
||||
let discordActions = discord?["actions"]?.dictionaryValue
|
||||
self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true
|
||||
self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true
|
||||
self.discordActionPolls = discordActions?["polls"]?.boolValue ?? true
|
||||
self.discordActionPermissions = discordActions?["permissions"]?.boolValue ?? true
|
||||
self.discordActionMessages = discordActions?["messages"]?.boolValue ?? true
|
||||
self.discordActionThreads = discordActions?["threads"]?.boolValue ?? true
|
||||
self.discordActionPins = discordActions?["pins"]?.boolValue ?? true
|
||||
self.discordActionSearch = discordActions?["search"]?.boolValue ?? true
|
||||
self.discordActionMemberInfo = discordActions?["memberInfo"]?.boolValue ?? true
|
||||
self.discordActionRoleInfo = discordActions?["roleInfo"]?.boolValue ?? true
|
||||
self.discordActionChannelInfo = discordActions?["channelInfo"]?.boolValue ?? true
|
||||
self.discordActionVoiceStatus = discordActions?["voiceStatus"]?.boolValue ?? true
|
||||
self.discordActionEvents = discordActions?["events"]?.boolValue ?? true
|
||||
self.discordActionRoles = discordActions?["roles"]?.boolValue ?? false
|
||||
self.discordActionModeration = discordActions?["moderation"]?.boolValue ?? false
|
||||
|
||||
let slash = discord?["slashCommand"]?.dictionaryValue
|
||||
self.discordSlashEnabled = slash?["enabled"]?.boolValue ?? false
|
||||
self.discordSlashName = slash?["name"]?.stringValue ?? ""
|
||||
self.discordSlashSessionPrefix = slash?["sessionPrefix"]?.stringValue ?? ""
|
||||
self.discordSlashEphemeral = slash?["ephemeral"]?.boolValue ?? true
|
||||
}
|
||||
|
||||
private func decodeDiscordGuilds(_ guilds: [String: AnyCodable]?) -> [DiscordGuildForm] {
|
||||
guard let guilds else { return [] }
|
||||
return guilds
|
||||
.map { key, value in
|
||||
let entry = value.dictionaryValue ?? [:]
|
||||
let slug = entry["slug"]?.stringValue ?? ""
|
||||
let requireMention = entry["requireMention"]?.boolValue ?? false
|
||||
let reactionModeRaw = entry["reactionNotifications"]?.stringValue ?? ""
|
||||
let reactionNotifications = ["off", "own", "all", "allowlist"].contains(reactionModeRaw)
|
||||
? reactionModeRaw
|
||||
: "own"
|
||||
let users = self.stringList(from: entry["users"]?.arrayValue)
|
||||
let channels: [DiscordGuildChannelForm] = if let channelMap = entry["channels"]?.dictionaryValue {
|
||||
channelMap.map { channelKey, channelValue in
|
||||
let channelEntry = channelValue.dictionaryValue ?? [:]
|
||||
let allow = channelEntry["allow"]?.boolValue ?? true
|
||||
let channelRequireMention = channelEntry["requireMention"]?.boolValue ?? false
|
||||
return DiscordGuildChannelForm(
|
||||
key: channelKey,
|
||||
allow: allow,
|
||||
requireMention: channelRequireMention)
|
||||
}
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
return DiscordGuildForm(
|
||||
key: key,
|
||||
slug: slug,
|
||||
requireMention: requireMention,
|
||||
reactionNotifications: reactionNotifications,
|
||||
users: users,
|
||||
channels: channels)
|
||||
}
|
||||
.sorted { $0.key < $1.key }
|
||||
}
|
||||
|
||||
private func applySignalConfig(_ snap: ConfigSnapshot) {
|
||||
let signal = self.resolveChannelConfig(snap, key: "signal")
|
||||
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
|
||||
self.signalAccount = signal?["account"]?.stringValue ?? ""
|
||||
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
|
||||
self.signalHttpHost = signal?["httpHost"]?.stringValue ?? ""
|
||||
self.signalHttpPort = self.numberString(from: signal?["httpPort"])
|
||||
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
|
||||
self.signalAllowFrom = self.stringList(from: signal?["allowFrom"]?.arrayValue)
|
||||
self.signalMediaMaxMb = self.numberString(from: signal?["mediaMaxMb"])
|
||||
}
|
||||
|
||||
private func applyIMessageConfig(_ snap: ConfigSnapshot) {
|
||||
let imessage = self.resolveChannelConfig(snap, key: "imessage")
|
||||
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 ?? ""
|
||||
self.imessageAllowFrom = self.stringList(from: imessage?["allowFrom"]?.arrayValue)
|
||||
self.imessageIncludeAttachments = imessage?["includeAttachments"]?.boolValue ?? false
|
||||
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
|
||||
}
|
||||
|
||||
private func channelConfigRoot(for key: String) -> [String: Any] {
|
||||
if let channels = self.configRoot["channels"] as? [String: Any],
|
||||
let entry = channels[key] as? [String: Any]
|
||||
{
|
||||
return entry
|
||||
}
|
||||
return self.configRoot[key] as? [String: Any] ?? [:]
|
||||
}
|
||||
|
||||
func saveTelegramConfig() async {
|
||||
guard !self.isSavingConfig else { return }
|
||||
self.isSavingConfig = true
|
||||
defer { self.isSavingConfig = false }
|
||||
if !self.configLoaded {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var telegram: [String: Any] = [:]
|
||||
if !self.isTelegramTokenLocked {
|
||||
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
|
||||
}
|
||||
telegram["requireMention"] = NSNull()
|
||||
telegram["groups"] = [
|
||||
"*": [
|
||||
"requireMention": self.telegramRequireMention,
|
||||
],
|
||||
]
|
||||
let allow = self.splitCsv(self.telegramAllowFrom)
|
||||
self.setPatchList(&telegram, key: "allowFrom", values: allow)
|
||||
self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
|
||||
self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
|
||||
self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
|
||||
self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
|
||||
|
||||
await self.persistChannelPatch("telegram", payload: telegram)
|
||||
}
|
||||
|
||||
func saveDiscordConfig() async {
|
||||
guard !self.isSavingConfig else { return }
|
||||
self.isSavingConfig = true
|
||||
defer { self.isSavingConfig = false }
|
||||
if !self.configLoaded {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
let base = self.channelConfigRoot(for: "discord")
|
||||
let discord = self.buildDiscordPatch(base: base)
|
||||
await self.persistChannelPatch("discord", payload: discord)
|
||||
}
|
||||
|
||||
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.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
|
||||
self.setPatchString(&signal, key: "account", value: self.signalAccount)
|
||||
self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
|
||||
self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost)
|
||||
self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort)
|
||||
self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath)
|
||||
self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
|
||||
self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
|
||||
self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
|
||||
self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
|
||||
self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
|
||||
let allow = self.splitCsv(self.signalAllowFrom)
|
||||
self.setPatchList(&signal, key: "allowFrom", values: allow)
|
||||
self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
|
||||
|
||||
await self.persistChannelPatch("signal", payload: signal)
|
||||
}
|
||||
|
||||
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.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
|
||||
self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
|
||||
self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
|
||||
|
||||
let service = self.trimmed(self.imessageService)
|
||||
if service.isEmpty || service == "auto" {
|
||||
imessage["service"] = NSNull()
|
||||
} else {
|
||||
imessage["service"] = service
|
||||
}
|
||||
|
||||
self.setPatchString(&imessage, key: "region", value: self.imessageRegion)
|
||||
|
||||
let allow = self.splitCsv(self.imessageAllowFrom)
|
||||
self.setPatchList(&imessage, key: "allowFrom", values: allow)
|
||||
|
||||
self.setPatchBool(
|
||||
&imessage,
|
||||
key: "includeAttachments",
|
||||
value: self.imessageIncludeAttachments,
|
||||
defaultValue: false)
|
||||
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
|
||||
|
||||
await self.persistChannelPatch("imessage", payload: imessage)
|
||||
}
|
||||
|
||||
private func buildDiscordPatch(base: [String: Any]) -> [String: Any] {
|
||||
var discord: [String: Any] = [:]
|
||||
self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
|
||||
if !self.isDiscordTokenLocked {
|
||||
self.setPatchString(&discord, key: "token", value: self.discordToken)
|
||||
}
|
||||
|
||||
if let dm = self.buildDiscordDmPatch() {
|
||||
discord["dm"] = dm
|
||||
} else {
|
||||
discord["dm"] = NSNull()
|
||||
}
|
||||
|
||||
self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
|
||||
self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
|
||||
self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
|
||||
|
||||
let replyToMode = self.trimmed(self.discordReplyToMode)
|
||||
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
|
||||
discord["replyToMode"] = NSNull()
|
||||
} else {
|
||||
discord["replyToMode"] = replyToMode
|
||||
}
|
||||
|
||||
let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
|
||||
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
|
||||
discord["guilds"] = guilds
|
||||
} else {
|
||||
discord["guilds"] = NSNull()
|
||||
}
|
||||
|
||||
if let actions = self.buildDiscordActionsPatch() {
|
||||
discord["actions"] = actions
|
||||
} else {
|
||||
discord["actions"] = NSNull()
|
||||
}
|
||||
|
||||
if let slash = self.buildDiscordSlashPatch() {
|
||||
discord["slashCommand"] = slash
|
||||
} else {
|
||||
discord["slashCommand"] = NSNull()
|
||||
}
|
||||
|
||||
return discord
|
||||
}
|
||||
|
||||
private func buildDiscordDmPatch() -> [String: Any]? {
|
||||
var dm: [String: Any] = [:]
|
||||
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
|
||||
let allow = self.splitCsv(self.discordAllowFrom)
|
||||
self.setPatchList(&dm, key: "allowFrom", values: allow)
|
||||
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
|
||||
let groupChannels = self.splitCsv(self.discordGroupChannels)
|
||||
self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
|
||||
return dm.isEmpty ? nil : dm
|
||||
}
|
||||
|
||||
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
|
||||
if self.discordGuilds.isEmpty {
|
||||
return NSNull()
|
||||
}
|
||||
var patch: [String: Any] = [:]
|
||||
let baseKeys = Set(base.keys)
|
||||
var formKeys = Set<String>()
|
||||
for entry in self.discordGuilds {
|
||||
let key = self.trimmed(entry.key)
|
||||
guard !key.isEmpty else { continue }
|
||||
formKeys.insert(key)
|
||||
let baseGuild = base[key] as? [String: Any] ?? [:]
|
||||
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild)
|
||||
}
|
||||
for key in baseKeys.subtracting(formKeys) {
|
||||
patch[key] = NSNull()
|
||||
}
|
||||
return patch.isEmpty ? NSNull() : patch
|
||||
}
|
||||
|
||||
private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
|
||||
var payload: [String: Any] = [:]
|
||||
let slug = self.trimmed(entry.slug)
|
||||
if slug.isEmpty {
|
||||
payload["slug"] = NSNull()
|
||||
} else {
|
||||
payload["slug"] = slug
|
||||
}
|
||||
if entry.requireMention {
|
||||
payload["requireMention"] = true
|
||||
} else {
|
||||
payload["requireMention"] = NSNull()
|
||||
}
|
||||
if ["off", "all", "allowlist"].contains(entry.reactionNotifications) {
|
||||
payload["reactionNotifications"] = entry.reactionNotifications
|
||||
} else {
|
||||
payload["reactionNotifications"] = NSNull()
|
||||
}
|
||||
let users = self.splitCsv(entry.users)
|
||||
self.setPatchList(&payload, key: "users", values: users)
|
||||
|
||||
let baseChannels = base["channels"] as? [String: Any] ?? [:]
|
||||
if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) {
|
||||
payload["channels"] = channels
|
||||
} else {
|
||||
payload["channels"] = NSNull()
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? {
|
||||
if forms.isEmpty {
|
||||
return NSNull()
|
||||
}
|
||||
var patch: [String: Any] = [:]
|
||||
let baseKeys = Set(base.keys)
|
||||
var formKeys = Set<String>()
|
||||
for channel in forms {
|
||||
let channelKey = self.trimmed(channel.key)
|
||||
guard !channelKey.isEmpty else { continue }
|
||||
formKeys.insert(channelKey)
|
||||
var channelPayload: [String: Any] = [:]
|
||||
self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true)
|
||||
self.setPatchBool(
|
||||
&channelPayload,
|
||||
key: "requireMention",
|
||||
value: channel.requireMention,
|
||||
defaultValue: false)
|
||||
patch[channelKey] = channelPayload
|
||||
}
|
||||
for key in baseKeys.subtracting(formKeys) {
|
||||
patch[key] = NSNull()
|
||||
}
|
||||
return patch.isEmpty ? NSNull() : patch
|
||||
}
|
||||
|
||||
private func buildDiscordActionsPatch() -> [String: Any]? {
|
||||
var actions: [String: Any] = [:]
|
||||
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
|
||||
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
|
||||
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
|
||||
self.setAction(&actions, key: "permissions", value: self.discordActionPermissions, defaultValue: true)
|
||||
self.setAction(&actions, key: "messages", value: self.discordActionMessages, defaultValue: true)
|
||||
self.setAction(&actions, key: "threads", value: self.discordActionThreads, defaultValue: true)
|
||||
self.setAction(&actions, key: "pins", value: self.discordActionPins, defaultValue: true)
|
||||
self.setAction(&actions, key: "search", value: self.discordActionSearch, defaultValue: true)
|
||||
self.setAction(&actions, key: "memberInfo", value: self.discordActionMemberInfo, defaultValue: true)
|
||||
self.setAction(&actions, key: "roleInfo", value: self.discordActionRoleInfo, defaultValue: true)
|
||||
self.setAction(&actions, key: "channelInfo", value: self.discordActionChannelInfo, defaultValue: true)
|
||||
self.setAction(&actions, key: "voiceStatus", value: self.discordActionVoiceStatus, defaultValue: true)
|
||||
self.setAction(&actions, key: "events", value: self.discordActionEvents, defaultValue: true)
|
||||
self.setAction(&actions, key: "roles", value: self.discordActionRoles, defaultValue: false)
|
||||
self.setAction(&actions, key: "moderation", value: self.discordActionModeration, defaultValue: false)
|
||||
return actions.isEmpty ? nil : actions
|
||||
}
|
||||
|
||||
private func buildDiscordSlashPatch() -> [String: Any]? {
|
||||
var slash: [String: Any] = [:]
|
||||
self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
|
||||
self.setPatchString(&slash, key: "name", value: self.discordSlashName)
|
||||
self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
|
||||
self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
|
||||
return slash.isEmpty ? nil : slash
|
||||
}
|
||||
|
||||
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
|
||||
do {
|
||||
guard let baseHash = self.configHash else {
|
||||
self.configStatus = "Config hash missing; reload and retry."
|
||||
return
|
||||
}
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: ["channels": [channelId: payload]],
|
||||
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),
|
||||
"baseHash": AnyCodable(baseHash),
|
||||
]
|
||||
_ = try await GatewayConnection.shared.requestRaw(
|
||||
method: .configPatch,
|
||||
params: params,
|
||||
timeoutMs: 10000)
|
||||
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
|
||||
await self.loadConfig()
|
||||
await self.refresh(probe: true)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func stringList(from values: [AnyCodable]?) -> String {
|
||||
guard let values else { return "" }
|
||||
let strings = values.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
|
||||
}
|
||||
return strings.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private func numberString(from value: AnyCodable?) -> String {
|
||||
if let number = value?.doubleValue ?? value?.intValue.map(Double.init) {
|
||||
return String(Int(number))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private func replyMode(from value: String?) -> String {
|
||||
if let value, ["off", "first", "all"].contains(value) {
|
||||
return value
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
|
||||
private func splitCsv(_ value: String) -> [String] {
|
||||
value
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private func trimmed(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func setPatchString(_ target: inout [String: Any], key: String, value: String) {
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target[key] = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) {
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
if let number = Double(trimmed) {
|
||||
target[key] = number
|
||||
} else {
|
||||
target[key] = NSNull()
|
||||
}
|
||||
}
|
||||
|
||||
private func setPatchInt(
|
||||
_ target: inout [String: Any],
|
||||
key: String,
|
||||
value: String,
|
||||
allowZero: Bool)
|
||||
{
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
guard let number = Int(trimmed) else {
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
let isValid = allowZero ? number >= 0 : number > 0
|
||||
guard isValid else {
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
target[key] = number
|
||||
}
|
||||
|
||||
private func setPatchBool(
|
||||
_ target: inout [String: Any],
|
||||
key: String,
|
||||
value: Bool,
|
||||
defaultValue: Bool)
|
||||
{
|
||||
if value == defaultValue {
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
|
||||
if values.isEmpty {
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target[key] = values
|
||||
}
|
||||
}
|
||||
|
||||
private func setAction(
|
||||
_ actions: inout [String: Any],
|
||||
key: String,
|
||||
value: Bool,
|
||||
defaultValue: Bool)
|
||||
{
|
||||
if value == defaultValue {
|
||||
actions[key] = NSNull()
|
||||
} else {
|
||||
actions[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ actor GatewayConnection {
|
||||
case configGet = "config.get"
|
||||
case configSet = "config.set"
|
||||
case configPatch = "config.patch"
|
||||
case configSchema = "config.schema"
|
||||
case wizardStart = "wizard.start"
|
||||
case wizardNext = "wizard.next"
|
||||
case wizardCancel = "wizard.cancel"
|
||||
|
||||
@@ -694,10 +694,10 @@ extension OnboardingView {
|
||||
systemImage: "bubble.left.and.bubble.right")
|
||||
self.featureActionRow(
|
||||
title: "Connect WhatsApp or Telegram",
|
||||
subtitle: "Open Settings → Connections to link channels and monitor status.",
|
||||
subtitle: "Open Settings → Channels to link channels and monitor status.",
|
||||
systemImage: "link")
|
||||
{
|
||||
self.openSettings(tab: .connections)
|
||||
self.openSettings(tab: .channels)
|
||||
}
|
||||
self.featureRow(
|
||||
title: "Try Voice Wake",
|
||||
|
||||
@@ -27,9 +27,9 @@ struct SettingsRootView: View {
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
.tag(SettingsTab.general)
|
||||
|
||||
ConnectionsSettings()
|
||||
.tabItem { Label("Connections", systemImage: "link") }
|
||||
.tag(SettingsTab.connections)
|
||||
ChannelsSettings()
|
||||
.tabItem { Label("Channels", systemImage: "link") }
|
||||
.tag(SettingsTab.channels)
|
||||
|
||||
VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake)
|
||||
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
|
||||
@@ -176,13 +176,13 @@ struct SettingsRootView: View {
|
||||
}
|
||||
|
||||
enum SettingsTab: CaseIterable {
|
||||
case general, connections, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
||||
case general, channels, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
||||
static let windowWidth: CGFloat = 824 // wider
|
||||
static let windowHeight: CGFloat = 790 // +10% (more room)
|
||||
var title: String {
|
||||
switch self {
|
||||
case .general: "General"
|
||||
case .connections: "Connections"
|
||||
case .channels: "Channels"
|
||||
case .skills: "Skills"
|
||||
case .sessions: "Sessions"
|
||||
case .cron: "Cron"
|
||||
@@ -198,7 +198,7 @@ enum SettingsTab: CaseIterable {
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .general: "gearshape"
|
||||
case .connections: "link"
|
||||
case .channels: "link"
|
||||
case .skills: "sparkles"
|
||||
case .sessions: "clock.arrow.circlepath"
|
||||
case .cron: "calendar"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ClawdbotConfig } from "../../../src/config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
import { MSTeamsConfigSchema } from "../../../src/config/zod-schema.providers-core.js";
|
||||
import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
|
||||
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
||||
import { msteamsOutbound } from "./outbound.js";
|
||||
@@ -64,6 +66,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
media: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.msteams"] },
|
||||
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
|
||||
config: {
|
||||
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
||||
resolveAccount: (cfg) => ({
|
||||
|
||||
12
src/channels/plugins/config-schema.ts
Normal file
12
src/channels/plugins/config-schema.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ZodTypeAny } from "zod";
|
||||
|
||||
import type { ChannelConfigSchema } from "./types.js";
|
||||
|
||||
export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema {
|
||||
return {
|
||||
schema: schema.toJSONSchema({
|
||||
target: "draft-07",
|
||||
unrepresentable: "any",
|
||||
}) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js";
|
||||
import { shouldLogVerbose } from "../../globals.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import { DiscordConfigSchema } from "../../config/zod-schema.providers-core.js";
|
||||
import { discordMessageActions } from "./actions/discord.js";
|
||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
@@ -57,6 +59,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
reload: { configPrefixes: ["channels.discord"] },
|
||||
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listDiscordAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
|
||||
|
||||
@@ -9,6 +9,8 @@ import { probeIMessage } from "../../imessage/probe.js";
|
||||
import { sendMessageIMessage } from "../../imessage/send.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import { IMessageConfigSchema } from "../../config/zod-schema.providers-core.js";
|
||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
@@ -44,6 +46,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
media: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.imessage"] },
|
||||
configSchema: buildChannelConfigSchema(IMessageConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listIMessageAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }),
|
||||
|
||||
@@ -10,6 +10,8 @@ import { probeSignal } from "../../signal/probe.js";
|
||||
import { sendMessageSignal } from "../../signal/send.js";
|
||||
import { normalizeE164 } from "../../utils.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import { SignalConfigSchema } from "../../config/zod-schema.providers-core.js";
|
||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
@@ -48,6 +50,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
reload: { configPrefixes: ["channels.signal"] },
|
||||
configSchema: buildChannelConfigSchema(SignalConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listSignalAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }),
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { probeSlack } from "../../slack/probe.js";
|
||||
import { sendMessageSlack } from "../../slack/send.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import { SlackConfigSchema } from "../../config/zod-schema.providers-core.js";
|
||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
@@ -80,6 +82,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
reload: { configPrefixes: ["channels.slack"] },
|
||||
configSchema: buildChannelConfigSchema(SlackConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listSlackAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
|
||||
|
||||
@@ -17,7 +17,9 @@ import { probeTelegram } from "../../telegram/probe.js";
|
||||
import { sendMessageTelegram } from "../../telegram/send.js";
|
||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import { TelegramConfigSchema } from "../../config/zod-schema.providers-core.js";
|
||||
import { telegramMessageActions } from "./actions/telegram.js";
|
||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
@@ -77,6 +79,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.telegram"] },
|
||||
configSchema: buildChannelConfigSchema(TelegramConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listTelegramAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }),
|
||||
|
||||
@@ -29,6 +29,20 @@ import type {
|
||||
|
||||
// Channel docking: implement this contract in src/channels/plugins/<id>.ts.
|
||||
// biome-ignore lint/suspicious/noExplicitAny: registry aggregates heterogeneous account types.
|
||||
export type ChannelConfigUiHint = {
|
||||
label?: string;
|
||||
help?: string;
|
||||
advanced?: boolean;
|
||||
sensitive?: boolean;
|
||||
placeholder?: string;
|
||||
itemTemplate?: unknown;
|
||||
};
|
||||
|
||||
export type ChannelConfigSchema = {
|
||||
schema: Record<string, unknown>;
|
||||
uiHints?: Record<string, ChannelConfigUiHint>;
|
||||
};
|
||||
|
||||
export type ChannelPlugin<ResolvedAccount = any> = {
|
||||
id: ChannelId;
|
||||
meta: ChannelMeta;
|
||||
@@ -37,6 +51,7 @@ export type ChannelPlugin<ResolvedAccount = any> = {
|
||||
// CLI onboarding wizard hooks for this channel.
|
||||
onboarding?: ChannelOnboardingAdapter;
|
||||
config: ChannelConfigAdapter<ResolvedAccount>;
|
||||
configSchema?: ChannelConfigSchema;
|
||||
setup?: ChannelSetupAdapter;
|
||||
pairing?: ChannelPairingAdapter;
|
||||
security?: ChannelSecurityAdapter<ResolvedAccount>;
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import { WhatsAppConfigSchema } from "../../config/zod-schema.providers-whatsapp.js";
|
||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
||||
import { createWhatsAppLoginTool } from "./agent-tools/whatsapp-login.js";
|
||||
import { resolveWhatsAppGroupRequireMention } from "./group-mentions.js";
|
||||
import { formatPairingApproveHint } from "./helpers.js";
|
||||
@@ -60,6 +62,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
},
|
||||
reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] },
|
||||
gatewayMethods: ["web.login.start", "web.login.wait"],
|
||||
configSchema: buildChannelConfigSchema(WhatsAppConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listWhatsAppAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }),
|
||||
|
||||
@@ -36,4 +36,52 @@ describe("config schema", () => {
|
||||
);
|
||||
expect(res.uiHints["plugins.entries.voice-call.config.twilio.authToken"]?.sensitive).toBe(true);
|
||||
});
|
||||
|
||||
it("merges plugin + channel schemas", () => {
|
||||
const res = buildConfigSchema({
|
||||
plugins: [
|
||||
{
|
||||
id: "voice-call",
|
||||
name: "Voice Call",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
provider: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
channels: [
|
||||
{
|
||||
id: "matrix",
|
||||
label: "Matrix",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
accessToken: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const schema = res.schema as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
const pluginsNode = schema.properties?.plugins as Record<string, unknown> | undefined;
|
||||
const entriesNode = pluginsNode?.properties as Record<string, unknown> | undefined;
|
||||
const entriesProps = entriesNode?.entries as Record<string, unknown> | undefined;
|
||||
const entryProps = entriesProps?.properties as Record<string, unknown> | undefined;
|
||||
const pluginEntry = entryProps?.["voice-call"] as Record<string, unknown> | undefined;
|
||||
const pluginConfig = pluginEntry?.properties as Record<string, unknown> | undefined;
|
||||
const pluginConfigSchema = pluginConfig?.config as Record<string, unknown> | undefined;
|
||||
const pluginConfigProps = pluginConfigSchema?.properties as Record<string, unknown> | undefined;
|
||||
expect(pluginConfigProps?.provider).toBeTruthy();
|
||||
|
||||
const channelsNode = schema.properties?.channels as Record<string, unknown> | undefined;
|
||||
const channelsProps = channelsNode?.properties as Record<string, unknown> | undefined;
|
||||
const channelSchema = channelsProps?.matrix as Record<string, unknown> | undefined;
|
||||
const channelProps = channelSchema?.properties as Record<string, unknown> | undefined;
|
||||
expect(channelProps?.accessToken).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ export type ConfigUiHints = Record<string, ConfigUiHint>;
|
||||
|
||||
export type ConfigSchema = ReturnType<typeof ClawdbotSchema.toJSONSchema>;
|
||||
|
||||
type JsonSchemaNode = Record<string, unknown>;
|
||||
|
||||
export type ConfigSchemaResponse = {
|
||||
schema: ConfigSchema;
|
||||
uiHints: ConfigUiHints;
|
||||
@@ -31,12 +33,15 @@ export type PluginUiMetadata = {
|
||||
string,
|
||||
Pick<ConfigUiHint, "label" | "help" | "advanced" | "sensitive" | "placeholder">
|
||||
>;
|
||||
configSchema?: JsonSchemaNode;
|
||||
};
|
||||
|
||||
export type ChannelUiMetadata = {
|
||||
id: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
configSchema?: JsonSchemaNode;
|
||||
configUiHints?: Record<string, ConfigUiHint>;
|
||||
};
|
||||
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
@@ -433,6 +438,51 @@ function isSensitivePath(path: string): boolean {
|
||||
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path));
|
||||
}
|
||||
|
||||
type JsonSchemaObject = JsonSchemaNode & {
|
||||
type?: string | string[];
|
||||
properties?: Record<string, JsonSchemaObject>;
|
||||
required?: string[];
|
||||
additionalProperties?: JsonSchemaObject | boolean;
|
||||
};
|
||||
|
||||
function cloneSchema<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") return structuredClone(value);
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function asSchemaObject(value: unknown): JsonSchemaObject | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as JsonSchemaObject;
|
||||
}
|
||||
|
||||
function isObjectSchema(schema: JsonSchemaObject): boolean {
|
||||
const type = schema.type;
|
||||
if (type === "object") return true;
|
||||
if (Array.isArray(type) && type.includes("object")) return true;
|
||||
return Boolean(schema.properties || schema.additionalProperties);
|
||||
}
|
||||
|
||||
function mergeObjectSchema(base: JsonSchemaObject, extension: JsonSchemaObject): JsonSchemaObject {
|
||||
const mergedRequired = new Set<string>([
|
||||
...(base.required ?? []),
|
||||
...(extension.required ?? []),
|
||||
]);
|
||||
const merged: JsonSchemaObject = {
|
||||
...base,
|
||||
...extension,
|
||||
properties: {
|
||||
...base.properties,
|
||||
...extension.properties,
|
||||
},
|
||||
};
|
||||
if (mergedRequired.size > 0) {
|
||||
merged.required = Array.from(mergedRequired);
|
||||
}
|
||||
const additional = extension.additionalProperties ?? base.additionalProperties;
|
||||
if (additional !== undefined) merged.additionalProperties = additional;
|
||||
return merged;
|
||||
}
|
||||
|
||||
function buildBaseHints(): ConfigUiHints {
|
||||
const hints: ConfigUiHints = {};
|
||||
for (const [group, label] of Object.entries(GROUP_LABELS)) {
|
||||
@@ -520,12 +570,90 @@ function applyChannelHints(hints: ConfigUiHints, channels: ChannelUiMetadata[]):
|
||||
...(label ? { label } : {}),
|
||||
...(help ? { help } : {}),
|
||||
};
|
||||
|
||||
const uiHints = channel.configUiHints ?? {};
|
||||
for (const [relPathRaw, hint] of Object.entries(uiHints)) {
|
||||
const relPath = relPathRaw.trim().replace(/^\./, "");
|
||||
if (!relPath) continue;
|
||||
const key = `${basePath}.${relPath}`;
|
||||
next[key] = {
|
||||
...next[key],
|
||||
...hint,
|
||||
};
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]): ConfigSchema {
|
||||
const next = cloneSchema(schema);
|
||||
const root = asSchemaObject(next);
|
||||
const pluginsNode = asSchemaObject(root?.properties?.plugins);
|
||||
const entriesNode = asSchemaObject(pluginsNode?.properties?.entries);
|
||||
if (!entriesNode) return next;
|
||||
|
||||
const entryBase = asSchemaObject(entriesNode.additionalProperties);
|
||||
const entryProperties = entriesNode.properties ?? {};
|
||||
entriesNode.properties = entryProperties;
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin.configSchema) continue;
|
||||
const entrySchema = entryBase ? cloneSchema(entryBase) : ({ type: "object" } as JsonSchemaObject);
|
||||
const entryObject = asSchemaObject(entrySchema) ?? ({ type: "object" } as JsonSchemaObject);
|
||||
const baseConfigSchema = asSchemaObject(entryObject.properties?.config);
|
||||
const pluginSchema = asSchemaObject(plugin.configSchema);
|
||||
const nextConfigSchema =
|
||||
baseConfigSchema && pluginSchema && isObjectSchema(baseConfigSchema) && isObjectSchema(pluginSchema)
|
||||
? mergeObjectSchema(baseConfigSchema, pluginSchema)
|
||||
: cloneSchema(plugin.configSchema);
|
||||
|
||||
entryObject.properties = {
|
||||
...entryObject.properties,
|
||||
config: nextConfigSchema,
|
||||
};
|
||||
entryProperties[plugin.id] = entryObject;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyChannelSchemas(schema: ConfigSchema, channels: ChannelUiMetadata[]): ConfigSchema {
|
||||
const next = cloneSchema(schema);
|
||||
const root = asSchemaObject(next);
|
||||
const channelsNode = asSchemaObject(root?.properties?.channels);
|
||||
if (!channelsNode) return next;
|
||||
const channelProps = channelsNode.properties ?? {};
|
||||
channelsNode.properties = channelProps;
|
||||
|
||||
for (const channel of channels) {
|
||||
if (!channel.configSchema) continue;
|
||||
const existing = asSchemaObject(channelProps[channel.id]);
|
||||
const incoming = asSchemaObject(channel.configSchema);
|
||||
if (existing && incoming && isObjectSchema(existing) && isObjectSchema(incoming)) {
|
||||
channelProps[channel.id] = mergeObjectSchema(existing, incoming);
|
||||
} else {
|
||||
channelProps[channel.id] = cloneSchema(channel.configSchema);
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
let cachedBase: ConfigSchemaResponse | null = null;
|
||||
|
||||
function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
|
||||
const next = cloneSchema(schema);
|
||||
const root = asSchemaObject(next);
|
||||
if (!root || !root.properties) return next;
|
||||
const channelsNode = asSchemaObject(root.properties.channels);
|
||||
if (channelsNode) {
|
||||
channelsNode.properties = {};
|
||||
channelsNode.required = [];
|
||||
channelsNode.additionalProperties = true;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildBaseConfigSchema(): ConfigSchemaResponse {
|
||||
if (cachedBase) return cachedBase;
|
||||
const schema = ClawdbotSchema.toJSONSchema({
|
||||
@@ -535,7 +663,7 @@ function buildBaseConfigSchema(): ConfigSchemaResponse {
|
||||
schema.title = "ClawdbotConfig";
|
||||
const hints = applySensitiveHints(buildBaseHints());
|
||||
const next = {
|
||||
schema,
|
||||
schema: stripChannelSchema(schema),
|
||||
uiHints: hints,
|
||||
version: VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
@@ -552,11 +680,16 @@ export function buildConfigSchema(params?: {
|
||||
const plugins = params?.plugins ?? [];
|
||||
const channels = params?.channels ?? [];
|
||||
if (plugins.length === 0 && channels.length === 0) return base;
|
||||
const merged = applySensitiveHints(
|
||||
const mergedHints = applySensitiveHints(
|
||||
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
|
||||
);
|
||||
const mergedSchema = applyChannelSchemas(
|
||||
applyPluginSchemas(base.schema, plugins),
|
||||
channels,
|
||||
);
|
||||
return {
|
||||
...base,
|
||||
uiHints: merged,
|
||||
schema: mergedSchema,
|
||||
uiHints: mergedHints,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { applyLegacyMigrations } from "../config/legacy.js";
|
||||
import { applyMergePatch } from "../config/merge-patch.js";
|
||||
import { buildConfigSchema } from "../config/schema.js";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { loadClawdbotPlugins } from "../plugins/loader.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
@@ -114,11 +115,14 @@ export const handleConfigBridgeMethods: BridgeMethodHandler = async (
|
||||
name: plugin.name,
|
||||
description: plugin.description,
|
||||
configUiHints: plugin.configUiHints,
|
||||
configSchema: plugin.configJsonSchema,
|
||||
})),
|
||||
channels: pluginRegistry.channels.map((entry) => ({
|
||||
id: entry.plugin.id,
|
||||
label: entry.plugin.meta.label,
|
||||
description: entry.plugin.meta.blurb,
|
||||
channels: listChannelPlugins().map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.meta.label,
|
||||
description: entry.meta.blurb,
|
||||
configSchema: entry.configSchema?.schema,
|
||||
configUiHints: entry.configSchema?.uiHints,
|
||||
})),
|
||||
});
|
||||
return { ok: true, payloadJSON: JSON.stringify(schema) };
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type RestartSentinelPayload,
|
||||
writeRestartSentinel,
|
||||
} from "../../infra/restart-sentinel.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { loadClawdbotPlugins } from "../../plugins/loader.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
@@ -127,11 +128,14 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
name: plugin.name,
|
||||
description: plugin.description,
|
||||
configUiHints: plugin.configUiHints,
|
||||
configSchema: plugin.configJsonSchema,
|
||||
})),
|
||||
channels: pluginRegistry.channels.map((entry) => ({
|
||||
id: entry.plugin.id,
|
||||
label: entry.plugin.meta.label,
|
||||
description: entry.plugin.meta.blurb,
|
||||
channels: listChannelPlugins().map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.meta.label,
|
||||
description: entry.meta.blurb,
|
||||
configSchema: entry.configSchema?.schema,
|
||||
configUiHints: entry.configSchema?.uiHints,
|
||||
})),
|
||||
});
|
||||
respond(true, schema, undefined);
|
||||
|
||||
@@ -197,6 +197,7 @@ function createPluginRecord(params: {
|
||||
httpHandlers: 0,
|
||||
configSchema: params.configSchema,
|
||||
configUiHints: undefined,
|
||||
configJsonSchema: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -302,6 +303,17 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
PluginConfigUiHint
|
||||
>)
|
||||
: undefined;
|
||||
record.configJsonSchema =
|
||||
definition?.configSchema &&
|
||||
typeof definition.configSchema === "object" &&
|
||||
(definition.configSchema as { jsonSchema?: unknown }).jsonSchema &&
|
||||
typeof (definition.configSchema as { jsonSchema?: unknown }).jsonSchema === "object" &&
|
||||
!Array.isArray((definition.configSchema as { jsonSchema?: unknown }).jsonSchema)
|
||||
? ((definition.configSchema as { jsonSchema?: unknown }).jsonSchema as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: undefined;
|
||||
|
||||
const validatedConfig = validatePluginConfig({
|
||||
schema: definition?.configSchema,
|
||||
|
||||
@@ -80,6 +80,7 @@ export type PluginRecord = {
|
||||
httpHandlers: number;
|
||||
configSchema: boolean;
|
||||
configUiHints?: Record<string, PluginConfigUiHint>;
|
||||
configJsonSchema?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PluginRegistry = {
|
||||
|
||||
@@ -42,6 +42,7 @@ export type ClawdbotPluginConfigSchema = {
|
||||
parse?: (value: unknown) => unknown;
|
||||
validate?: (value: unknown) => PluginConfigValidation;
|
||||
uiHints?: Record<string, PluginConfigUiHint>;
|
||||
jsonSchema?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ClawdbotPluginToolContext = {
|
||||
|
||||
34
ui/src/ui/app-channels.ts
Normal file
34
ui/src/ui/app-channels.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
loadChannels,
|
||||
logoutWhatsApp,
|
||||
startWhatsAppLogin,
|
||||
waitWhatsAppLogin,
|
||||
} from "./controllers/channels";
|
||||
import { loadConfig, saveConfig } from "./controllers/config";
|
||||
import type { ClawdbotApp } from "./app";
|
||||
|
||||
export async function handleWhatsAppStart(host: ClawdbotApp, force: boolean) {
|
||||
await startWhatsAppLogin(host, force);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleWhatsAppWait(host: ClawdbotApp) {
|
||||
await waitWhatsAppLogin(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleWhatsAppLogout(host: ClawdbotApp) {
|
||||
await logoutWhatsApp(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleChannelConfigSave(host: ClawdbotApp) {
|
||||
await saveConfig(host);
|
||||
await loadConfig(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleChannelConfigReload(host: ClawdbotApp) {
|
||||
await loadConfig(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import {
|
||||
loadChannels,
|
||||
logoutWhatsApp,
|
||||
saveDiscordConfig,
|
||||
saveIMessageConfig,
|
||||
saveSlackConfig,
|
||||
saveSignalConfig,
|
||||
saveTelegramConfig,
|
||||
startWhatsAppLogin,
|
||||
waitWhatsAppLogin,
|
||||
} from "./controllers/connections";
|
||||
import { loadConfig } from "./controllers/config";
|
||||
import type { ClawdbotApp } from "./app";
|
||||
|
||||
export async function handleWhatsAppStart(host: ClawdbotApp, force: boolean) {
|
||||
await startWhatsAppLogin(host, force);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleWhatsAppWait(host: ClawdbotApp) {
|
||||
await waitWhatsAppLogin(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleWhatsAppLogout(host: ClawdbotApp) {
|
||||
await logoutWhatsApp(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleTelegramSave(host: ClawdbotApp) {
|
||||
await saveTelegramConfig(host);
|
||||
await loadConfig(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleDiscordSave(host: ClawdbotApp) {
|
||||
await saveDiscordConfig(host);
|
||||
await loadConfig(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleSlackSave(host: ClawdbotApp) {
|
||||
await saveSlackConfig(host);
|
||||
await loadConfig(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleSignalSave(host: ClawdbotApp) {
|
||||
await saveSignalConfig(host);
|
||||
await loadConfig(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleIMessageSave(host: ClawdbotApp) {
|
||||
await saveIMessageConfig(host);
|
||||
await loadConfig(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
@@ -27,18 +27,10 @@ import type {
|
||||
SkillStatusReport,
|
||||
StatusSummary,
|
||||
} from "./types";
|
||||
import type {
|
||||
ChatQueueItem,
|
||||
CronFormState,
|
||||
DiscordForm,
|
||||
IMessageForm,
|
||||
SlackForm,
|
||||
SignalForm,
|
||||
TelegramForm,
|
||||
} from "./ui-types";
|
||||
import type { ChatQueueItem, CronFormState } from "./ui-types";
|
||||
import { renderChat } from "./views/chat";
|
||||
import { renderConfig } from "./views/config";
|
||||
import { renderConnections } from "./views/connections";
|
||||
import { renderChannels } from "./views/channels";
|
||||
import { renderCron } from "./views/cron";
|
||||
import { renderDebug } from "./views/debug";
|
||||
import { renderInstances } from "./views/instances";
|
||||
@@ -48,14 +40,7 @@ import { renderOverview } from "./views/overview";
|
||||
import { renderSessions } from "./views/sessions";
|
||||
import { renderSkills } from "./views/skills";
|
||||
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
|
||||
import {
|
||||
loadChannels,
|
||||
updateDiscordForm,
|
||||
updateIMessageForm,
|
||||
updateSlackForm,
|
||||
updateSignalForm,
|
||||
updateTelegramForm,
|
||||
} from "./controllers/connections";
|
||||
import { loadChannels } from "./controllers/channels";
|
||||
import { loadPresence } from "./controllers/presence";
|
||||
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions";
|
||||
import {
|
||||
@@ -205,8 +190,8 @@ export function renderApp(state: AppViewState) {
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "connections"
|
||||
? renderConnections({
|
||||
${state.tab === "channels"
|
||||
? renderChannels({
|
||||
connected: state.connected,
|
||||
loading: state.channelsLoading,
|
||||
snapshot: state.channelsSnapshot,
|
||||
@@ -216,39 +201,19 @@ export function renderApp(state: AppViewState) {
|
||||
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
|
||||
whatsappConnected: state.whatsappLoginConnected,
|
||||
whatsappBusy: state.whatsappBusy,
|
||||
telegramForm: state.telegramForm,
|
||||
telegramTokenLocked: state.telegramTokenLocked,
|
||||
telegramSaving: state.telegramSaving,
|
||||
telegramStatus: state.telegramConfigStatus,
|
||||
discordForm: state.discordForm,
|
||||
discordTokenLocked: state.discordTokenLocked,
|
||||
discordSaving: state.discordSaving,
|
||||
discordStatus: state.discordConfigStatus,
|
||||
slackForm: state.slackForm,
|
||||
slackTokenLocked: state.slackTokenLocked,
|
||||
slackAppTokenLocked: state.slackAppTokenLocked,
|
||||
slackSaving: state.slackSaving,
|
||||
slackStatus: state.slackConfigStatus,
|
||||
signalForm: state.signalForm,
|
||||
signalSaving: state.signalSaving,
|
||||
signalStatus: state.signalConfigStatus,
|
||||
imessageForm: state.imessageForm,
|
||||
imessageSaving: state.imessageSaving,
|
||||
imessageStatus: state.imessageConfigStatus,
|
||||
configSchema: state.configSchema,
|
||||
configSchemaLoading: state.configSchemaLoading,
|
||||
configForm: state.configForm,
|
||||
configUiHints: state.configUiHints,
|
||||
configSaving: state.configSaving,
|
||||
configFormDirty: state.configFormDirty,
|
||||
onRefresh: (probe) => loadChannels(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(),
|
||||
onSlackChange: (patch) => updateSlackForm(state, patch),
|
||||
onSlackSave: () => state.handleSlackSave(),
|
||||
onSignalChange: (patch) => updateSignalForm(state, patch),
|
||||
onSignalSave: () => state.handleSignalSave(),
|
||||
onIMessageChange: (patch) => updateIMessageForm(state, patch),
|
||||
onIMessageSave: () => state.handleIMessageSave(),
|
||||
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onConfigSave: () => state.handleChannelConfigSave(),
|
||||
onConfigReload: () => state.handleChannelConfigReload(),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { loadConfig, loadConfigSchema } from "./controllers/config";
|
||||
import { loadCronJobs, loadCronStatus } from "./controllers/cron";
|
||||
import { loadChannels } from "./controllers/connections";
|
||||
import { loadChannels } from "./controllers/channels";
|
||||
import { loadDebug } from "./controllers/debug";
|
||||
import { loadLogs } from "./controllers/logs";
|
||||
import { loadNodes } from "./controllers/nodes";
|
||||
@@ -125,7 +125,7 @@ export function setTheme(
|
||||
|
||||
export async function refreshActiveTab(host: SettingsHost) {
|
||||
if (host.tab === "overview") await loadOverview(host);
|
||||
if (host.tab === "connections") await loadConnections(host);
|
||||
if (host.tab === "channels") await loadChannelsTab(host);
|
||||
if (host.tab === "instances") await loadPresence(host as unknown as ClawdbotApp);
|
||||
if (host.tab === "sessions") await loadSessions(host as unknown as ClawdbotApp);
|
||||
if (host.tab === "cron") await loadCron(host);
|
||||
@@ -256,9 +256,10 @@ export async function loadOverview(host: SettingsHost) {
|
||||
]);
|
||||
}
|
||||
|
||||
export async function loadConnections(host: SettingsHost) {
|
||||
export async function loadChannelsTab(host: SettingsHost) {
|
||||
await Promise.all([
|
||||
loadChannels(host as unknown as ClawdbotApp, true),
|
||||
loadConfigSchema(host as unknown as ClawdbotApp),
|
||||
loadConfig(host as unknown as ClawdbotApp),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -17,15 +17,7 @@ import type {
|
||||
SkillStatusReport,
|
||||
StatusSummary,
|
||||
} from "./types";
|
||||
import type {
|
||||
ChatQueueItem,
|
||||
CronFormState,
|
||||
DiscordForm,
|
||||
IMessageForm,
|
||||
SlackForm,
|
||||
SignalForm,
|
||||
TelegramForm,
|
||||
} from "./ui-types";
|
||||
import type { ChatQueueItem, CronFormState } from "./ui-types";
|
||||
import type { EventLogEntry } from "./app-events";
|
||||
import type { SkillMessage } from "./controllers/skills";
|
||||
|
||||
@@ -73,25 +65,7 @@ export type AppViewState = {
|
||||
whatsappLoginQrDataUrl: string | null;
|
||||
whatsappLoginConnected: boolean | null;
|
||||
whatsappBusy: boolean;
|
||||
telegramForm: TelegramForm;
|
||||
telegramSaving: boolean;
|
||||
telegramTokenLocked: boolean;
|
||||
telegramConfigStatus: string | null;
|
||||
discordForm: DiscordForm;
|
||||
discordSaving: boolean;
|
||||
discordTokenLocked: boolean;
|
||||
discordConfigStatus: string | null;
|
||||
slackForm: SlackForm;
|
||||
slackSaving: boolean;
|
||||
slackTokenLocked: boolean;
|
||||
slackAppTokenLocked: boolean;
|
||||
slackConfigStatus: string | null;
|
||||
signalForm: SignalForm;
|
||||
signalSaving: boolean;
|
||||
signalConfigStatus: string | null;
|
||||
imessageForm: IMessageForm;
|
||||
imessageSaving: boolean;
|
||||
imessageConfigStatus: string | null;
|
||||
configFormDirty: boolean;
|
||||
presenceLoading: boolean;
|
||||
presenceEntries: PresenceEntry[];
|
||||
presenceError: string | null;
|
||||
@@ -145,11 +119,8 @@ export type AppViewState = {
|
||||
handleWhatsAppStart: (force: boolean) => Promise<void>;
|
||||
handleWhatsAppWait: () => Promise<void>;
|
||||
handleWhatsAppLogout: () => Promise<void>;
|
||||
handleTelegramSave: () => Promise<void>;
|
||||
handleDiscordSave: () => Promise<void>;
|
||||
handleSlackSave: () => Promise<void>;
|
||||
handleSignalSave: () => Promise<void>;
|
||||
handleIMessageSave: () => Promise<void>;
|
||||
handleChannelConfigSave: () => Promise<void>;
|
||||
handleChannelConfigReload: () => Promise<void>;
|
||||
handleConfigLoad: () => Promise<void>;
|
||||
handleConfigSave: () => Promise<void>;
|
||||
handleConfigApply: () => Promise<void>;
|
||||
@@ -188,10 +159,5 @@ export type AppViewState = {
|
||||
handleLogsLevelFilterToggle: (level: LogLevel) => void;
|
||||
handleLogsAutoFollowToggle: (next: boolean) => void;
|
||||
handleCallDebugMethod: (method: string, params: string) => Promise<void>;
|
||||
handleUpdateDiscordForm: (path: string, value: unknown) => void;
|
||||
handleUpdateSlackForm: (path: string, value: unknown) => void;
|
||||
handleUpdateSignalForm: (path: string, value: unknown) => void;
|
||||
handleUpdateTelegramForm: (path: string, value: unknown) => void;
|
||||
handleUpdateIMessageForm: (path: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
|
||||
126
ui/src/ui/app.ts
126
ui/src/ui/app.ts
@@ -21,17 +21,7 @@ import type {
|
||||
SkillStatusReport,
|
||||
StatusSummary,
|
||||
} from "./types";
|
||||
import {
|
||||
defaultDiscordActions,
|
||||
defaultSlackActions,
|
||||
type ChatQueueItem,
|
||||
type CronFormState,
|
||||
type DiscordForm,
|
||||
type IMessageForm,
|
||||
type SlackForm,
|
||||
type SignalForm,
|
||||
type TelegramForm,
|
||||
} from "./ui-types";
|
||||
import { type ChatQueueItem, type CronFormState } from "./ui-types";
|
||||
import type { EventLogEntry } from "./app-events";
|
||||
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
|
||||
import {
|
||||
@@ -66,15 +56,12 @@ import {
|
||||
removeQueuedMessage as removeQueuedMessageInternal,
|
||||
} from "./app-chat";
|
||||
import {
|
||||
handleDiscordSave as handleDiscordSaveInternal,
|
||||
handleIMessageSave as handleIMessageSaveInternal,
|
||||
handleSignalSave as handleSignalSaveInternal,
|
||||
handleSlackSave as handleSlackSaveInternal,
|
||||
handleTelegramSave as handleTelegramSaveInternal,
|
||||
handleChannelConfigReload as handleChannelConfigReloadInternal,
|
||||
handleChannelConfigSave as handleChannelConfigSaveInternal,
|
||||
handleWhatsAppLogout as handleWhatsAppLogoutInternal,
|
||||
handleWhatsAppStart as handleWhatsAppStartInternal,
|
||||
handleWhatsAppWait as handleWhatsAppWaitInternal,
|
||||
} from "./app-connections";
|
||||
} from "./app-channels";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -143,91 +130,6 @@ export class ClawdbotApp extends LitElement {
|
||||
@state() whatsappLoginQrDataUrl: string | null = null;
|
||||
@state() whatsappLoginConnected: boolean | null = null;
|
||||
@state() whatsappBusy = false;
|
||||
@state() telegramForm: TelegramForm = {
|
||||
token: "",
|
||||
requireMention: true,
|
||||
groupsWildcardEnabled: false,
|
||||
allowFrom: "",
|
||||
proxy: "",
|
||||
webhookUrl: "",
|
||||
webhookSecret: "",
|
||||
webhookPath: "",
|
||||
};
|
||||
@state() telegramSaving = false;
|
||||
@state() telegramTokenLocked = false;
|
||||
@state() telegramConfigStatus: string | null = null;
|
||||
@state() discordForm: DiscordForm = {
|
||||
enabled: true,
|
||||
token: "",
|
||||
dmEnabled: true,
|
||||
allowFrom: "",
|
||||
groupEnabled: false,
|
||||
groupChannels: "",
|
||||
mediaMaxMb: "",
|
||||
historyLimit: "",
|
||||
textChunkLimit: "",
|
||||
guilds: [],
|
||||
actions: { ...defaultDiscordActions },
|
||||
slashEnabled: false,
|
||||
slashName: "",
|
||||
slashSessionPrefix: "",
|
||||
slashEphemeral: true,
|
||||
};
|
||||
@state() discordSaving = false;
|
||||
@state() discordTokenLocked = false;
|
||||
@state() discordConfigStatus: string | null = null;
|
||||
@state() slackForm: SlackForm = {
|
||||
enabled: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
dmEnabled: true,
|
||||
allowFrom: "",
|
||||
groupEnabled: false,
|
||||
groupChannels: "",
|
||||
mediaMaxMb: "",
|
||||
textChunkLimit: "",
|
||||
reactionNotifications: "own",
|
||||
reactionAllowlist: "",
|
||||
slashEnabled: false,
|
||||
slashName: "",
|
||||
slashSessionPrefix: "",
|
||||
slashEphemeral: true,
|
||||
actions: { ...defaultSlackActions },
|
||||
channels: [],
|
||||
};
|
||||
@state() slackSaving = false;
|
||||
@state() slackTokenLocked = false;
|
||||
@state() slackAppTokenLocked = false;
|
||||
@state() slackConfigStatus: 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[] = [];
|
||||
@@ -439,24 +341,12 @@ export class ClawdbotApp extends LitElement {
|
||||
await handleWhatsAppLogoutInternal(this);
|
||||
}
|
||||
|
||||
async handleTelegramSave() {
|
||||
await handleTelegramSaveInternal(this);
|
||||
async handleChannelConfigSave() {
|
||||
await handleChannelConfigSaveInternal(this);
|
||||
}
|
||||
|
||||
async handleDiscordSave() {
|
||||
await handleDiscordSaveInternal(this);
|
||||
}
|
||||
|
||||
async handleSlackSave() {
|
||||
await handleSlackSaveInternal(this);
|
||||
}
|
||||
|
||||
async handleSignalSave() {
|
||||
await handleSignalSaveInternal(this);
|
||||
}
|
||||
|
||||
async handleIMessageSave() {
|
||||
await handleIMessageSaveInternal(this);
|
||||
async handleChannelConfigReload() {
|
||||
await handleChannelConfigReloadInternal(this);
|
||||
}
|
||||
|
||||
// Sidebar handlers for tool output viewing
|
||||
|
||||
76
ui/src/ui/controllers/channels.ts
Normal file
76
ui/src/ui/controllers/channels.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { ChannelsStatusSnapshot } from "../types";
|
||||
import type { ChannelsState } from "./channels.types";
|
||||
|
||||
export type { ChannelsState };
|
||||
|
||||
export async function loadChannels(state: ChannelsState, probe: boolean) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.channelsLoading) return;
|
||||
state.channelsLoading = true;
|
||||
state.channelsError = null;
|
||||
try {
|
||||
const res = (await state.client.request("channels.status", {
|
||||
probe,
|
||||
timeoutMs: 8000,
|
||||
})) as ChannelsStatusSnapshot;
|
||||
state.channelsSnapshot = res;
|
||||
state.channelsLastSuccess = Date.now();
|
||||
} catch (err) {
|
||||
state.channelsError = String(err);
|
||||
} finally {
|
||||
state.channelsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startWhatsAppLogin(state: ChannelsState, force: boolean) {
|
||||
if (!state.client || !state.connected || state.whatsappBusy) return;
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
const res = (await state.client.request("web.login.start", {
|
||||
force,
|
||||
timeoutMs: 30000,
|
||||
})) as { message?: string; qrDataUrl?: string };
|
||||
state.whatsappLoginMessage = res.message ?? null;
|
||||
state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null;
|
||||
state.whatsappLoginConnected = null;
|
||||
} catch (err) {
|
||||
state.whatsappLoginMessage = String(err);
|
||||
state.whatsappLoginQrDataUrl = null;
|
||||
state.whatsappLoginConnected = null;
|
||||
} finally {
|
||||
state.whatsappBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitWhatsAppLogin(state: ChannelsState) {
|
||||
if (!state.client || !state.connected || state.whatsappBusy) return;
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
const res = (await state.client.request("web.login.wait", {
|
||||
timeoutMs: 120000,
|
||||
})) as { connected?: boolean; message?: string };
|
||||
state.whatsappLoginMessage = res.message ?? null;
|
||||
state.whatsappLoginConnected = res.connected ?? null;
|
||||
if (res.connected) state.whatsappLoginQrDataUrl = null;
|
||||
} catch (err) {
|
||||
state.whatsappLoginMessage = String(err);
|
||||
state.whatsappLoginConnected = null;
|
||||
} finally {
|
||||
state.whatsappBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutWhatsApp(state: ChannelsState) {
|
||||
if (!state.client || !state.connected || state.whatsappBusy) return;
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
await state.client.request("channels.logout", { channel: "whatsapp" });
|
||||
state.whatsappLoginMessage = "Logged out.";
|
||||
state.whatsappLoginQrDataUrl = null;
|
||||
state.whatsappLoginConnected = null;
|
||||
} catch (err) {
|
||||
state.whatsappLoginMessage = String(err);
|
||||
} finally {
|
||||
state.whatsappBusy = false;
|
||||
}
|
||||
}
|
||||
15
ui/src/ui/controllers/channels.types.ts
Normal file
15
ui/src/ui/controllers/channels.types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import type { ChannelsStatusSnapshot } from "../types";
|
||||
|
||||
export type ChannelsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
channelsLoading: boolean;
|
||||
channelsSnapshot: ChannelsStatusSnapshot | null;
|
||||
channelsError: string | null;
|
||||
channelsLastSuccess: number | null;
|
||||
whatsappLoginMessage: string | null;
|
||||
whatsappLoginQrDataUrl: string | null;
|
||||
whatsappLoginConnected: boolean | null;
|
||||
whatsappBusy: boolean;
|
||||
};
|
||||
@@ -7,92 +7,6 @@ import {
|
||||
updateConfigFormValue,
|
||||
type ConfigState,
|
||||
} from "./config";
|
||||
import {
|
||||
defaultDiscordActions,
|
||||
defaultSlackActions,
|
||||
type DiscordForm,
|
||||
type IMessageForm,
|
||||
type SignalForm,
|
||||
type SlackForm,
|
||||
type TelegramForm,
|
||||
} from "../ui-types";
|
||||
|
||||
const baseTelegramForm: TelegramForm = {
|
||||
token: "",
|
||||
requireMention: true,
|
||||
groupsWildcardEnabled: false,
|
||||
allowFrom: "",
|
||||
proxy: "",
|
||||
webhookUrl: "",
|
||||
webhookSecret: "",
|
||||
webhookPath: "",
|
||||
};
|
||||
|
||||
const baseDiscordForm: DiscordForm = {
|
||||
enabled: true,
|
||||
token: "",
|
||||
dmEnabled: true,
|
||||
allowFrom: "",
|
||||
groupEnabled: false,
|
||||
groupChannels: "",
|
||||
mediaMaxMb: "",
|
||||
historyLimit: "",
|
||||
textChunkLimit: "",
|
||||
replyToMode: "off",
|
||||
guilds: [],
|
||||
actions: { ...defaultDiscordActions },
|
||||
slashEnabled: false,
|
||||
slashName: "",
|
||||
slashSessionPrefix: "",
|
||||
slashEphemeral: true,
|
||||
};
|
||||
|
||||
const baseSlackForm: SlackForm = {
|
||||
enabled: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
dmEnabled: true,
|
||||
allowFrom: "",
|
||||
groupEnabled: false,
|
||||
groupChannels: "",
|
||||
mediaMaxMb: "",
|
||||
textChunkLimit: "",
|
||||
reactionNotifications: "own",
|
||||
reactionAllowlist: "",
|
||||
slashEnabled: false,
|
||||
slashName: "",
|
||||
slashSessionPrefix: "",
|
||||
slashEphemeral: true,
|
||||
actions: { ...defaultSlackActions },
|
||||
channels: [],
|
||||
};
|
||||
|
||||
const baseSignalForm: SignalForm = {
|
||||
enabled: true,
|
||||
account: "",
|
||||
httpUrl: "",
|
||||
httpHost: "",
|
||||
httpPort: "",
|
||||
cliPath: "",
|
||||
autoStart: true,
|
||||
receiveMode: "",
|
||||
ignoreAttachments: false,
|
||||
ignoreStories: false,
|
||||
sendReadReceipts: false,
|
||||
allowFrom: "",
|
||||
mediaMaxMb: "",
|
||||
};
|
||||
|
||||
const baseIMessageForm: IMessageForm = {
|
||||
enabled: true,
|
||||
cliPath: "",
|
||||
dbPath: "",
|
||||
service: "auto",
|
||||
region: "",
|
||||
allowFrom: "",
|
||||
includeAttachments: false,
|
||||
mediaMaxMb: "",
|
||||
};
|
||||
|
||||
function createState(): ConfigState {
|
||||
return {
|
||||
@@ -115,40 +29,10 @@ function createState(): ConfigState {
|
||||
configFormDirty: false,
|
||||
configFormMode: "form",
|
||||
lastError: null,
|
||||
telegramForm: { ...baseTelegramForm },
|
||||
discordForm: { ...baseDiscordForm },
|
||||
slackForm: { ...baseSlackForm },
|
||||
signalForm: { ...baseSignalForm },
|
||||
imessageForm: { ...baseIMessageForm },
|
||||
telegramConfigStatus: null,
|
||||
discordConfigStatus: null,
|
||||
slackConfigStatus: null,
|
||||
signalConfigStatus: null,
|
||||
imessageConfigStatus: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyConfigSnapshot", () => {
|
||||
it("handles missing slack config without throwing", () => {
|
||||
const state = createState();
|
||||
applyConfigSnapshot(state, {
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {},
|
||||
discord: {},
|
||||
signal: {},
|
||||
imessage: {},
|
||||
},
|
||||
},
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: "{}",
|
||||
});
|
||||
|
||||
expect(state.slackForm.botToken).toBe("");
|
||||
expect(state.slackForm.actions).toEqual(defaultSlackActions);
|
||||
});
|
||||
|
||||
it("does not clobber form edits while dirty", () => {
|
||||
const state = createState();
|
||||
state.configFormMode = "form";
|
||||
@@ -167,6 +51,18 @@ describe("applyConfigSnapshot", () => {
|
||||
"{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates config form when clean", () => {
|
||||
const state = createState();
|
||||
applyConfigSnapshot(state, {
|
||||
config: { gateway: { mode: "local" } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: "{}",
|
||||
});
|
||||
|
||||
expect(state.configForm).toEqual({ gateway: { mode: "local" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateConfigFormValue", () => {
|
||||
|
||||
@@ -4,19 +4,6 @@ import type {
|
||||
ConfigSnapshot,
|
||||
ConfigUiHints,
|
||||
} from "../types";
|
||||
import {
|
||||
defaultDiscordActions,
|
||||
defaultSlackActions,
|
||||
type DiscordActionForm,
|
||||
type DiscordForm,
|
||||
type DiscordGuildChannelForm,
|
||||
type DiscordGuildForm,
|
||||
type IMessageForm,
|
||||
type SlackChannelForm,
|
||||
type SlackForm,
|
||||
type SignalForm,
|
||||
type TelegramForm,
|
||||
} from "../ui-types";
|
||||
import {
|
||||
cloneConfigObject,
|
||||
removePathValue,
|
||||
@@ -44,16 +31,6 @@ export type ConfigState = {
|
||||
configFormDirty: boolean;
|
||||
configFormMode: "form" | "raw";
|
||||
lastError: string | null;
|
||||
telegramForm: TelegramForm;
|
||||
discordForm: DiscordForm;
|
||||
slackForm: SlackForm;
|
||||
signalForm: SignalForm;
|
||||
imessageForm: IMessageForm;
|
||||
telegramConfigStatus: string | null;
|
||||
discordConfigStatus: string | null;
|
||||
slackConfigStatus: string | null;
|
||||
signalConfigStatus: string | null;
|
||||
imessageConfigStatus: string | null;
|
||||
};
|
||||
|
||||
export async function loadConfig(state: ConfigState) {
|
||||
@@ -114,285 +91,6 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
||||
state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null;
|
||||
state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : [];
|
||||
|
||||
const config = snapshot.config ?? {};
|
||||
const channels = (config.channels ?? {}) as Record<string, unknown>;
|
||||
const telegram = (channels.telegram ?? config.telegram ?? {}) as Record<string, unknown>;
|
||||
const discord = (channels.discord ?? config.discord ?? {}) as Record<string, unknown>;
|
||||
const slack = (channels.slack ?? config.slack ?? {}) as Record<string, unknown>;
|
||||
const signal = (channels.signal ?? config.signal ?? {}) as Record<string, unknown>;
|
||||
const imessage = (channels.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 telegramGroups =
|
||||
telegram.groups && typeof telegram.groups === "object"
|
||||
? (telegram.groups as Record<string, unknown>)
|
||||
: {};
|
||||
const telegramDefaultGroup =
|
||||
telegramGroups["*"] && typeof telegramGroups["*"] === "object"
|
||||
? (telegramGroups["*"] as Record<string, unknown>)
|
||||
: {};
|
||||
const telegramHasWildcard = Boolean(telegramGroups["*"]);
|
||||
const allowFrom = Array.isArray(telegram.allowFrom)
|
||||
? toList(telegram.allowFrom)
|
||||
: typeof telegram.allowFrom === "string"
|
||||
? telegram.allowFrom
|
||||
: "";
|
||||
|
||||
state.telegramForm = {
|
||||
token: typeof telegram.botToken === "string" ? telegram.botToken : "",
|
||||
requireMention:
|
||||
typeof telegramDefaultGroup.requireMention === "boolean"
|
||||
? telegramDefaultGroup.requireMention
|
||||
: true,
|
||||
groupsWildcardEnabled: telegramHasWildcard,
|
||||
allowFrom,
|
||||
proxy: typeof telegram.proxy === "string" ? telegram.proxy : "",
|
||||
webhookUrl: typeof telegram.webhookUrl === "string" ? telegram.webhookUrl : "",
|
||||
webhookSecret:
|
||||
typeof telegram.webhookSecret === "string" ? telegram.webhookSecret : "",
|
||||
webhookPath: typeof telegram.webhookPath === "string" ? telegram.webhookPath : "",
|
||||
};
|
||||
|
||||
const discordDm = (discord.dm ?? {}) as Record<string, unknown>;
|
||||
const slash = (discord.slashCommand ?? {}) as Record<string, unknown>;
|
||||
const discordActions = (discord.actions ?? {}) as Record<string, unknown>;
|
||||
const discordGuilds = discord.guilds;
|
||||
const readAction = (key: keyof DiscordActionForm) =>
|
||||
typeof discordActions[key] === "boolean"
|
||||
? (discordActions[key] as boolean)
|
||||
: defaultDiscordActions[key];
|
||||
state.discordForm = {
|
||||
enabled: typeof discord.enabled === "boolean" ? discord.enabled : true,
|
||||
token: typeof discord.token === "string" ? discord.token : "",
|
||||
dmEnabled: typeof discordDm.enabled === "boolean" ? discordDm.enabled : true,
|
||||
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) : "",
|
||||
textChunkLimit:
|
||||
typeof discord.textChunkLimit === "number"
|
||||
? String(discord.textChunkLimit)
|
||||
: "",
|
||||
replyToMode:
|
||||
discord.replyToMode === "first" || discord.replyToMode === "all"
|
||||
? discord.replyToMode
|
||||
: "off",
|
||||
guilds: Array.isArray(discordGuilds)
|
||||
? []
|
||||
: typeof discordGuilds === "object" && discordGuilds
|
||||
? Object.entries(discordGuilds as Record<string, unknown>).map(
|
||||
([key, value]): DiscordGuildForm => {
|
||||
const entry =
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const channelsRaw =
|
||||
entry.channels && typeof entry.channels === "object"
|
||||
? (entry.channels as Record<string, unknown>)
|
||||
: {};
|
||||
const channels = Object.entries(channelsRaw).map(
|
||||
([channelKey, channelValue]): DiscordGuildChannelForm => {
|
||||
const channel =
|
||||
channelValue && typeof channelValue === "object"
|
||||
? (channelValue as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
key: channelKey,
|
||||
allow:
|
||||
typeof channel.allow === "boolean" ? channel.allow : true,
|
||||
requireMention:
|
||||
typeof channel.requireMention === "boolean"
|
||||
? channel.requireMention
|
||||
: false,
|
||||
};
|
||||
},
|
||||
);
|
||||
return {
|
||||
key,
|
||||
slug: typeof entry.slug === "string" ? entry.slug : "",
|
||||
requireMention:
|
||||
typeof entry.requireMention === "boolean"
|
||||
? entry.requireMention
|
||||
: false,
|
||||
reactionNotifications:
|
||||
entry.reactionNotifications === "off" ||
|
||||
entry.reactionNotifications === "all" ||
|
||||
entry.reactionNotifications === "own" ||
|
||||
entry.reactionNotifications === "allowlist"
|
||||
? entry.reactionNotifications
|
||||
: "own",
|
||||
users: toList(entry.users),
|
||||
channels,
|
||||
};
|
||||
},
|
||||
)
|
||||
: [],
|
||||
actions: {
|
||||
reactions: readAction("reactions"),
|
||||
stickers: readAction("stickers"),
|
||||
polls: readAction("polls"),
|
||||
permissions: readAction("permissions"),
|
||||
messages: readAction("messages"),
|
||||
threads: readAction("threads"),
|
||||
pins: readAction("pins"),
|
||||
search: readAction("search"),
|
||||
memberInfo: readAction("memberInfo"),
|
||||
roleInfo: readAction("roleInfo"),
|
||||
channelInfo: readAction("channelInfo"),
|
||||
voiceStatus: readAction("voiceStatus"),
|
||||
events: readAction("events"),
|
||||
roles: readAction("roles"),
|
||||
moderation: readAction("moderation"),
|
||||
},
|
||||
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,
|
||||
};
|
||||
|
||||
const slackDm = (slack.dm ?? {}) as Record<string, unknown>;
|
||||
const slackChannels = slack.channels;
|
||||
const slackSlash = (slack.slashCommand ?? {}) as Record<string, unknown>;
|
||||
const slackActions =
|
||||
(slack.actions ?? {}) as Partial<Record<keyof typeof defaultSlackActions, unknown>>;
|
||||
state.slackForm = {
|
||||
enabled: typeof slack.enabled === "boolean" ? slack.enabled : true,
|
||||
botToken: typeof slack.botToken === "string" ? slack.botToken : "",
|
||||
appToken: typeof slack.appToken === "string" ? slack.appToken : "",
|
||||
dmEnabled: typeof slackDm.enabled === "boolean" ? slackDm.enabled : true,
|
||||
allowFrom: toList(slackDm.allowFrom),
|
||||
groupEnabled:
|
||||
typeof slackDm.groupEnabled === "boolean" ? slackDm.groupEnabled : false,
|
||||
groupChannels: toList(slackDm.groupChannels),
|
||||
mediaMaxMb:
|
||||
typeof slack.mediaMaxMb === "number" ? String(slack.mediaMaxMb) : "",
|
||||
textChunkLimit:
|
||||
typeof slack.textChunkLimit === "number"
|
||||
? String(slack.textChunkLimit)
|
||||
: "",
|
||||
reactionNotifications:
|
||||
slack.reactionNotifications === "off" ||
|
||||
slack.reactionNotifications === "all" ||
|
||||
slack.reactionNotifications === "allowlist"
|
||||
? slack.reactionNotifications
|
||||
: "own",
|
||||
reactionAllowlist: toList(slack.reactionAllowlist),
|
||||
slashEnabled:
|
||||
typeof slackSlash.enabled === "boolean" ? slackSlash.enabled : false,
|
||||
slashName: typeof slackSlash.name === "string" ? slackSlash.name : "",
|
||||
slashSessionPrefix:
|
||||
typeof slackSlash.sessionPrefix === "string"
|
||||
? slackSlash.sessionPrefix
|
||||
: "",
|
||||
slashEphemeral:
|
||||
typeof slackSlash.ephemeral === "boolean" ? slackSlash.ephemeral : true,
|
||||
actions: {
|
||||
...defaultSlackActions,
|
||||
reactions:
|
||||
typeof slackActions.reactions === "boolean"
|
||||
? slackActions.reactions
|
||||
: defaultSlackActions.reactions,
|
||||
messages:
|
||||
typeof slackActions.messages === "boolean"
|
||||
? slackActions.messages
|
||||
: defaultSlackActions.messages,
|
||||
pins:
|
||||
typeof slackActions.pins === "boolean"
|
||||
? slackActions.pins
|
||||
: defaultSlackActions.pins,
|
||||
memberInfo:
|
||||
typeof slackActions.memberInfo === "boolean"
|
||||
? slackActions.memberInfo
|
||||
: defaultSlackActions.memberInfo,
|
||||
emojiList:
|
||||
typeof slackActions.emojiList === "boolean"
|
||||
? slackActions.emojiList
|
||||
: defaultSlackActions.emojiList,
|
||||
},
|
||||
channels: Array.isArray(slackChannels)
|
||||
? []
|
||||
: typeof slackChannels === "object" && slackChannels
|
||||
? Object.entries(slackChannels as Record<string, unknown>).map(
|
||||
([key, value]): SlackChannelForm => {
|
||||
const entry =
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
key,
|
||||
allow:
|
||||
typeof entry.allow === "boolean" ? entry.allow : true,
|
||||
requireMention:
|
||||
typeof entry.requireMention === "boolean"
|
||||
? entry.requireMention
|
||||
: false,
|
||||
};
|
||||
},
|
||||
)
|
||||
: [],
|
||||
};
|
||||
|
||||
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.slackConfigStatus = configInvalid;
|
||||
state.signalConfigStatus = configInvalid;
|
||||
state.imessageConfigStatus = configInvalid;
|
||||
|
||||
if (!state.configFormDirty) {
|
||||
state.configForm = cloneConfigObject(snapshot.config ?? {});
|
||||
}
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { parseList } from "../format";
|
||||
import {
|
||||
defaultDiscordActions,
|
||||
type DiscordActionForm,
|
||||
type DiscordGuildChannelForm,
|
||||
type DiscordGuildForm,
|
||||
} from "../ui-types";
|
||||
import type { ConnectionsState } from "./connections.types";
|
||||
|
||||
export async function saveDiscordConfig(state: ConnectionsState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.discordSaving) return;
|
||||
state.discordSaving = true;
|
||||
state.discordConfigStatus = null;
|
||||
try {
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.discordConfigStatus = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
const discord: Record<string, unknown> = {};
|
||||
const form = state.discordForm;
|
||||
|
||||
if (form.enabled) {
|
||||
discord.enabled = null;
|
||||
} else {
|
||||
discord.enabled = false;
|
||||
}
|
||||
|
||||
if (!state.discordTokenLocked) {
|
||||
const token = form.token.trim();
|
||||
discord.token = token || null;
|
||||
}
|
||||
|
||||
const allowFrom = parseList(form.allowFrom);
|
||||
const groupChannels = parseList(form.groupChannels);
|
||||
const dm: Record<string, unknown> = {
|
||||
enabled: form.dmEnabled ? null : false,
|
||||
allowFrom: allowFrom.length > 0 ? allowFrom : null,
|
||||
groupEnabled: form.groupEnabled ? true : null,
|
||||
groupChannels: groupChannels.length > 0 ? groupChannels : null,
|
||||
};
|
||||
discord.dm = dm;
|
||||
|
||||
const mediaMaxMb = Number(form.mediaMaxMb);
|
||||
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
|
||||
discord.mediaMaxMb = mediaMaxMb;
|
||||
} else {
|
||||
discord.mediaMaxMb = null;
|
||||
}
|
||||
|
||||
const historyLimitRaw = form.historyLimit.trim();
|
||||
if (historyLimitRaw.length === 0) {
|
||||
discord.historyLimit = null;
|
||||
} else {
|
||||
const historyLimit = Number(historyLimitRaw);
|
||||
if (Number.isFinite(historyLimit) && historyLimit >= 0) {
|
||||
discord.historyLimit = historyLimit;
|
||||
} else {
|
||||
discord.historyLimit = null;
|
||||
}
|
||||
}
|
||||
|
||||
const chunkLimitRaw = form.textChunkLimit.trim();
|
||||
if (chunkLimitRaw.length === 0) {
|
||||
discord.textChunkLimit = null;
|
||||
} else {
|
||||
const chunkLimit = Number(chunkLimitRaw);
|
||||
if (Number.isFinite(chunkLimit) && chunkLimit > 0) {
|
||||
discord.textChunkLimit = chunkLimit;
|
||||
} else {
|
||||
discord.textChunkLimit = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (form.replyToMode === "off") {
|
||||
discord.replyToMode = null;
|
||||
} else {
|
||||
discord.replyToMode = form.replyToMode;
|
||||
}
|
||||
|
||||
const guildsForm = Array.isArray(form.guilds) ? form.guilds : [];
|
||||
const guilds: Record<string, unknown> = {};
|
||||
guildsForm.forEach((guild: DiscordGuildForm) => {
|
||||
const key = String(guild.key ?? "").trim();
|
||||
if (!key) return;
|
||||
const entry: Record<string, unknown> = {};
|
||||
const slug = String(guild.slug ?? "").trim();
|
||||
if (slug) entry.slug = slug;
|
||||
if (guild.requireMention) entry.requireMention = true;
|
||||
if (
|
||||
guild.reactionNotifications === "off" ||
|
||||
guild.reactionNotifications === "all" ||
|
||||
guild.reactionNotifications === "own" ||
|
||||
guild.reactionNotifications === "allowlist"
|
||||
) {
|
||||
entry.reactionNotifications = guild.reactionNotifications;
|
||||
}
|
||||
const users = parseList(guild.users);
|
||||
if (users.length > 0) entry.users = users;
|
||||
const channels: Record<string, unknown> = {};
|
||||
const channelForms = Array.isArray(guild.channels) ? guild.channels : [];
|
||||
channelForms.forEach((channel: DiscordGuildChannelForm) => {
|
||||
const channelKey = String(channel.key ?? "").trim();
|
||||
if (!channelKey) return;
|
||||
const channelEntry: Record<string, unknown> = {};
|
||||
if (channel.allow === false) channelEntry.allow = false;
|
||||
if (channel.requireMention) channelEntry.requireMention = true;
|
||||
channels[channelKey] = channelEntry;
|
||||
});
|
||||
if (Object.keys(channels).length > 0) entry.channels = channels;
|
||||
guilds[key] = entry;
|
||||
});
|
||||
if (Object.keys(guilds).length > 0) discord.guilds = guilds;
|
||||
else discord.guilds = null;
|
||||
|
||||
const actions: Partial<DiscordActionForm> = {};
|
||||
const applyAction = (key: keyof DiscordActionForm) => {
|
||||
const value = form.actions[key];
|
||||
if (value !== defaultDiscordActions[key]) actions[key] = value;
|
||||
};
|
||||
applyAction("reactions");
|
||||
applyAction("stickers");
|
||||
applyAction("polls");
|
||||
applyAction("permissions");
|
||||
applyAction("messages");
|
||||
applyAction("threads");
|
||||
applyAction("pins");
|
||||
applyAction("search");
|
||||
applyAction("memberInfo");
|
||||
applyAction("roleInfo");
|
||||
applyAction("channelInfo");
|
||||
applyAction("voiceStatus");
|
||||
applyAction("events");
|
||||
applyAction("roles");
|
||||
applyAction("moderation");
|
||||
if (Object.keys(actions).length > 0) {
|
||||
discord.actions = actions;
|
||||
} else {
|
||||
discord.actions = null;
|
||||
}
|
||||
|
||||
const slash = { ...(discord.slashCommand ?? {}) } as Record<string, unknown>;
|
||||
if (form.slashEnabled) {
|
||||
slash.enabled = true;
|
||||
} else {
|
||||
slash.enabled = null;
|
||||
}
|
||||
if (form.slashName.trim()) slash.name = form.slashName.trim();
|
||||
else slash.name = null;
|
||||
if (form.slashSessionPrefix.trim())
|
||||
slash.sessionPrefix = form.slashSessionPrefix.trim();
|
||||
else slash.sessionPrefix = null;
|
||||
if (form.slashEphemeral) {
|
||||
slash.ephemeral = null;
|
||||
} else {
|
||||
slash.ephemeral = false;
|
||||
}
|
||||
discord.slashCommand = Object.keys(slash).length > 0 ? slash : null;
|
||||
|
||||
const raw = `${JSON.stringify(
|
||||
{ channels: { discord } },
|
||||
null,
|
||||
2,
|
||||
).trimEnd()}\n`;
|
||||
await state.client.request("config.patch", { raw, baseHash });
|
||||
state.discordConfigStatus = "Saved. Restart gateway if needed.";
|
||||
} catch (err) {
|
||||
state.discordConfigStatus = String(err);
|
||||
} finally {
|
||||
state.discordSaving = false;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { parseList } from "../format";
|
||||
import type { ConnectionsState } from "./connections.types";
|
||||
|
||||
export async function saveIMessageConfig(state: ConnectionsState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.imessageSaving) return;
|
||||
state.imessageSaving = true;
|
||||
state.imessageConfigStatus = null;
|
||||
try {
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.imessageConfigStatus = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
const imessage: Record<string, unknown> = {};
|
||||
const form = state.imessageForm;
|
||||
|
||||
if (form.enabled) {
|
||||
imessage.enabled = null;
|
||||
} else {
|
||||
imessage.enabled = false;
|
||||
}
|
||||
|
||||
const cliPath = form.cliPath.trim();
|
||||
imessage.cliPath = cliPath || null;
|
||||
|
||||
const dbPath = form.dbPath.trim();
|
||||
imessage.dbPath = dbPath || null;
|
||||
|
||||
if (form.service === "auto") {
|
||||
imessage.service = null;
|
||||
} else {
|
||||
imessage.service = form.service;
|
||||
}
|
||||
|
||||
const region = form.region.trim();
|
||||
imessage.region = region || null;
|
||||
|
||||
const allowFrom = parseList(form.allowFrom);
|
||||
imessage.allowFrom = allowFrom.length > 0 ? allowFrom : null;
|
||||
|
||||
imessage.includeAttachments = form.includeAttachments ? true : null;
|
||||
|
||||
const mediaMaxMb = Number(form.mediaMaxMb);
|
||||
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
|
||||
imessage.mediaMaxMb = mediaMaxMb;
|
||||
} else {
|
||||
imessage.mediaMaxMb = null;
|
||||
}
|
||||
|
||||
const raw = `${JSON.stringify(
|
||||
{ channels: { imessage } },
|
||||
null,
|
||||
2,
|
||||
).trimEnd()}\n`;
|
||||
await state.client.request("config.patch", { raw, baseHash });
|
||||
state.imessageConfigStatus = "Saved. Restart gateway if needed.";
|
||||
} catch (err) {
|
||||
state.imessageConfigStatus = String(err);
|
||||
} finally {
|
||||
state.imessageSaving = false;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { parseList } from "../format";
|
||||
import type { ConnectionsState } from "./connections.types";
|
||||
|
||||
export async function saveSignalConfig(state: ConnectionsState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.signalSaving) return;
|
||||
state.signalSaving = true;
|
||||
state.signalConfigStatus = null;
|
||||
try {
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.signalConfigStatus = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
const signal: Record<string, unknown> = {};
|
||||
const form = state.signalForm;
|
||||
|
||||
if (form.enabled) {
|
||||
signal.enabled = null;
|
||||
} else {
|
||||
signal.enabled = false;
|
||||
}
|
||||
|
||||
const account = form.account.trim();
|
||||
signal.account = account || null;
|
||||
|
||||
const httpUrl = form.httpUrl.trim();
|
||||
signal.httpUrl = httpUrl || null;
|
||||
|
||||
const httpHost = form.httpHost.trim();
|
||||
signal.httpHost = httpHost || null;
|
||||
|
||||
const httpPort = Number(form.httpPort);
|
||||
if (Number.isFinite(httpPort) && httpPort > 0) {
|
||||
signal.httpPort = httpPort;
|
||||
} else {
|
||||
signal.httpPort = null;
|
||||
}
|
||||
|
||||
const cliPath = form.cliPath.trim();
|
||||
signal.cliPath = cliPath || null;
|
||||
|
||||
if (form.autoStart) {
|
||||
signal.autoStart = null;
|
||||
} else {
|
||||
signal.autoStart = false;
|
||||
}
|
||||
|
||||
if (form.receiveMode === "on-start" || form.receiveMode === "manual") {
|
||||
signal.receiveMode = form.receiveMode;
|
||||
} else {
|
||||
signal.receiveMode = null;
|
||||
}
|
||||
|
||||
signal.ignoreAttachments = form.ignoreAttachments ? true : null;
|
||||
signal.ignoreStories = form.ignoreStories ? true : null;
|
||||
signal.sendReadReceipts = form.sendReadReceipts ? true : null;
|
||||
|
||||
const allowFrom = parseList(form.allowFrom);
|
||||
signal.allowFrom = allowFrom.length > 0 ? allowFrom : null;
|
||||
|
||||
const mediaMaxMb = Number(form.mediaMaxMb);
|
||||
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
|
||||
signal.mediaMaxMb = mediaMaxMb;
|
||||
} else {
|
||||
signal.mediaMaxMb = null;
|
||||
}
|
||||
|
||||
const raw = `${JSON.stringify(
|
||||
{ channels: { signal } },
|
||||
null,
|
||||
2,
|
||||
).trimEnd()}\n`;
|
||||
await state.client.request("config.patch", { raw, baseHash });
|
||||
state.signalConfigStatus = "Saved. Restart gateway if needed.";
|
||||
} catch (err) {
|
||||
state.signalConfigStatus = String(err);
|
||||
} finally {
|
||||
state.signalSaving = false;
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { parseList } from "../format";
|
||||
import { defaultSlackActions, type SlackActionForm } from "../ui-types";
|
||||
import type { ConnectionsState } from "./connections.types";
|
||||
|
||||
export async function saveSlackConfig(state: ConnectionsState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.slackSaving) return;
|
||||
state.slackSaving = true;
|
||||
state.slackConfigStatus = null;
|
||||
try {
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.slackConfigStatus = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
const slack: Record<string, unknown> = {};
|
||||
const form = state.slackForm;
|
||||
|
||||
if (form.enabled) {
|
||||
slack.enabled = null;
|
||||
} else {
|
||||
slack.enabled = false;
|
||||
}
|
||||
|
||||
if (!state.slackTokenLocked) {
|
||||
const token = form.botToken.trim();
|
||||
slack.botToken = token || null;
|
||||
}
|
||||
if (!state.slackAppTokenLocked) {
|
||||
const token = form.appToken.trim();
|
||||
slack.appToken = token || null;
|
||||
}
|
||||
|
||||
const dm: Record<string, unknown> = {};
|
||||
dm.enabled = form.dmEnabled;
|
||||
const allowFrom = parseList(form.allowFrom);
|
||||
dm.allowFrom = allowFrom.length > 0 ? allowFrom : null;
|
||||
if (form.groupEnabled) {
|
||||
dm.groupEnabled = true;
|
||||
} else {
|
||||
dm.groupEnabled = null;
|
||||
}
|
||||
const groupChannels = parseList(form.groupChannels);
|
||||
dm.groupChannels = groupChannels.length > 0 ? groupChannels : null;
|
||||
slack.dm = dm;
|
||||
|
||||
const mediaMaxMb = Number.parseFloat(form.mediaMaxMb);
|
||||
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
|
||||
slack.mediaMaxMb = mediaMaxMb;
|
||||
} else {
|
||||
slack.mediaMaxMb = null;
|
||||
}
|
||||
|
||||
const textChunkLimit = Number.parseInt(form.textChunkLimit, 10);
|
||||
if (Number.isFinite(textChunkLimit) && textChunkLimit > 0) {
|
||||
slack.textChunkLimit = textChunkLimit;
|
||||
} else {
|
||||
slack.textChunkLimit = null;
|
||||
}
|
||||
|
||||
if (form.reactionNotifications === "own") {
|
||||
slack.reactionNotifications = null;
|
||||
} else {
|
||||
slack.reactionNotifications = form.reactionNotifications;
|
||||
}
|
||||
const reactionAllowlist = parseList(form.reactionAllowlist);
|
||||
if (reactionAllowlist.length > 0) {
|
||||
slack.reactionAllowlist = reactionAllowlist;
|
||||
} else {
|
||||
slack.reactionAllowlist = null;
|
||||
}
|
||||
|
||||
const slash: Record<string, unknown> = {};
|
||||
if (form.slashEnabled) {
|
||||
slash.enabled = true;
|
||||
} else {
|
||||
slash.enabled = null;
|
||||
}
|
||||
if (form.slashName.trim()) slash.name = form.slashName.trim();
|
||||
else slash.name = null;
|
||||
if (form.slashSessionPrefix.trim())
|
||||
slash.sessionPrefix = form.slashSessionPrefix.trim();
|
||||
else slash.sessionPrefix = null;
|
||||
if (form.slashEphemeral) {
|
||||
slash.ephemeral = null;
|
||||
} else {
|
||||
slash.ephemeral = false;
|
||||
}
|
||||
slack.slashCommand = slash;
|
||||
|
||||
const actions: Partial<SlackActionForm> = {};
|
||||
const applyAction = (key: keyof SlackActionForm) => {
|
||||
const value = form.actions[key];
|
||||
if (value !== defaultSlackActions[key]) actions[key] = value;
|
||||
};
|
||||
applyAction("reactions");
|
||||
applyAction("messages");
|
||||
applyAction("pins");
|
||||
applyAction("memberInfo");
|
||||
applyAction("emojiList");
|
||||
if (Object.keys(actions).length > 0) {
|
||||
slack.actions = actions;
|
||||
} else {
|
||||
slack.actions = null;
|
||||
}
|
||||
|
||||
const channels = form.channels
|
||||
.map((entry): [string, Record<string, unknown>] | null => {
|
||||
const key = entry.key.trim();
|
||||
if (!key) return null;
|
||||
const record: Record<string, unknown> = {
|
||||
allow: entry.allow,
|
||||
requireMention: entry.requireMention,
|
||||
};
|
||||
return [key, record];
|
||||
})
|
||||
.filter((value): value is [string, Record<string, unknown>] =>
|
||||
Boolean(value),
|
||||
);
|
||||
if (channels.length > 0) {
|
||||
slack.channels = Object.fromEntries(channels);
|
||||
} else {
|
||||
slack.channels = null;
|
||||
}
|
||||
|
||||
const raw = `${JSON.stringify(
|
||||
{ channels: { slack } },
|
||||
null,
|
||||
2,
|
||||
).trimEnd()}\n`;
|
||||
await state.client.request("config.patch", { raw, baseHash });
|
||||
state.slackConfigStatus = "Saved. Restart gateway if needed.";
|
||||
} catch (err) {
|
||||
state.slackConfigStatus = String(err);
|
||||
} finally {
|
||||
state.slackSaving = false;
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import { parseList } from "../format";
|
||||
import type { ChannelsStatusSnapshot } from "../types";
|
||||
import {
|
||||
type DiscordForm,
|
||||
type IMessageForm,
|
||||
type SlackForm,
|
||||
type SignalForm,
|
||||
type TelegramForm,
|
||||
} from "../ui-types";
|
||||
import type { ConnectionsState } from "./connections.types";
|
||||
|
||||
export { saveDiscordConfig } from "./connections.save-discord";
|
||||
export { saveIMessageConfig } from "./connections.save-imessage";
|
||||
export { saveSlackConfig } from "./connections.save-slack";
|
||||
export { saveSignalConfig } from "./connections.save-signal";
|
||||
|
||||
export type { ConnectionsState };
|
||||
|
||||
export async function loadChannels(state: ConnectionsState, probe: boolean) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.channelsLoading) return;
|
||||
state.channelsLoading = true;
|
||||
state.channelsError = null;
|
||||
try {
|
||||
const res = (await state.client.request("channels.status", {
|
||||
probe,
|
||||
timeoutMs: 8000,
|
||||
})) as ChannelsStatusSnapshot;
|
||||
state.channelsSnapshot = res;
|
||||
state.channelsLastSuccess = Date.now();
|
||||
const channels = res.channels as Record<string, unknown>;
|
||||
const telegram = channels.telegram as { tokenSource?: string | null };
|
||||
const discord = channels.discord as { tokenSource?: string | null } | null;
|
||||
const slack = channels.slack as
|
||||
| { botTokenSource?: string | null; appTokenSource?: string | null }
|
||||
| null;
|
||||
state.telegramTokenLocked = telegram?.tokenSource === "env";
|
||||
state.discordTokenLocked = discord?.tokenSource === "env";
|
||||
state.slackTokenLocked = slack?.botTokenSource === "env";
|
||||
state.slackAppTokenLocked = slack?.appTokenSource === "env";
|
||||
} catch (err) {
|
||||
state.channelsError = String(err);
|
||||
} finally {
|
||||
state.channelsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startWhatsAppLogin(state: ConnectionsState, force: boolean) {
|
||||
if (!state.client || !state.connected || state.whatsappBusy) return;
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
const res = (await state.client.request("web.login.start", {
|
||||
force,
|
||||
timeoutMs: 30000,
|
||||
})) as { message?: string; qrDataUrl?: string };
|
||||
state.whatsappLoginMessage = res.message ?? null;
|
||||
state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null;
|
||||
state.whatsappLoginConnected = null;
|
||||
} catch (err) {
|
||||
state.whatsappLoginMessage = String(err);
|
||||
state.whatsappLoginQrDataUrl = null;
|
||||
state.whatsappLoginConnected = null;
|
||||
} finally {
|
||||
state.whatsappBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitWhatsAppLogin(state: ConnectionsState) {
|
||||
if (!state.client || !state.connected || state.whatsappBusy) return;
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
const res = (await state.client.request("web.login.wait", {
|
||||
timeoutMs: 120000,
|
||||
})) as { connected?: boolean; message?: string };
|
||||
state.whatsappLoginMessage = res.message ?? null;
|
||||
state.whatsappLoginConnected = res.connected ?? null;
|
||||
if (res.connected) state.whatsappLoginQrDataUrl = null;
|
||||
} catch (err) {
|
||||
state.whatsappLoginMessage = String(err);
|
||||
state.whatsappLoginConnected = null;
|
||||
} finally {
|
||||
state.whatsappBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutWhatsApp(state: ConnectionsState) {
|
||||
if (!state.client || !state.connected || state.whatsappBusy) return;
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
await state.client.request("channels.logout", { channel: "whatsapp" });
|
||||
state.whatsappLoginMessage = "Logged out.";
|
||||
state.whatsappLoginQrDataUrl = null;
|
||||
state.whatsappLoginConnected = null;
|
||||
} catch (err) {
|
||||
state.whatsappLoginMessage = String(err);
|
||||
} finally {
|
||||
state.whatsappBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTelegramForm(
|
||||
state: ConnectionsState,
|
||||
patch: Partial<TelegramForm>,
|
||||
) {
|
||||
state.telegramForm = { ...state.telegramForm, ...patch };
|
||||
}
|
||||
|
||||
export function updateDiscordForm(
|
||||
state: ConnectionsState,
|
||||
patch: Partial<DiscordForm>,
|
||||
) {
|
||||
if (patch.actions) {
|
||||
state.discordForm = {
|
||||
...state.discordForm,
|
||||
...patch,
|
||||
actions: { ...state.discordForm.actions, ...patch.actions },
|
||||
};
|
||||
return;
|
||||
}
|
||||
state.discordForm = { ...state.discordForm, ...patch };
|
||||
}
|
||||
|
||||
export function updateSlackForm(
|
||||
state: ConnectionsState,
|
||||
patch: Partial<SlackForm>,
|
||||
) {
|
||||
if (patch.actions) {
|
||||
state.slackForm = {
|
||||
...state.slackForm,
|
||||
...patch,
|
||||
actions: { ...state.slackForm.actions, ...patch.actions },
|
||||
};
|
||||
return;
|
||||
}
|
||||
state.slackForm = { ...state.slackForm, ...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;
|
||||
state.telegramSaving = true;
|
||||
state.telegramConfigStatus = null;
|
||||
try {
|
||||
if (state.telegramForm.groupsWildcardEnabled) {
|
||||
const confirmed = window.confirm(
|
||||
'Telegram groups wildcard "*" allows all groups. Continue?',
|
||||
);
|
||||
if (!confirmed) {
|
||||
state.telegramConfigStatus = "Save cancelled.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
const base = state.configSnapshot?.config ?? {};
|
||||
const channels = (base.channels ?? {}) as Record<string, unknown>;
|
||||
const telegram = {
|
||||
...(channels.telegram ?? base.telegram ?? {}),
|
||||
} as Record<string, unknown>;
|
||||
if (!state.telegramTokenLocked) {
|
||||
const token = state.telegramForm.token.trim();
|
||||
telegram.botToken = token || null;
|
||||
}
|
||||
const groupsPatch: Record<string, unknown> = {};
|
||||
if (state.telegramForm.groupsWildcardEnabled) {
|
||||
const existingGroups = telegram.groups as Record<string, unknown> | undefined;
|
||||
const defaultGroup =
|
||||
existingGroups?.["*"] && typeof existingGroups["*"] === "object"
|
||||
? ({ ...(existingGroups["*"] as Record<string, unknown>) } as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: {};
|
||||
defaultGroup.requireMention = state.telegramForm.requireMention;
|
||||
groupsPatch["*"] = defaultGroup;
|
||||
} else {
|
||||
groupsPatch["*"] = null;
|
||||
}
|
||||
telegram.groups = groupsPatch;
|
||||
telegram.requireMention = null;
|
||||
const allowFrom = parseList(state.telegramForm.allowFrom);
|
||||
telegram.allowFrom = allowFrom.length > 0 ? allowFrom : null;
|
||||
const proxy = state.telegramForm.proxy.trim();
|
||||
telegram.proxy = proxy || null;
|
||||
const webhookUrl = state.telegramForm.webhookUrl.trim();
|
||||
telegram.webhookUrl = webhookUrl || null;
|
||||
const webhookSecret = state.telegramForm.webhookSecret.trim();
|
||||
telegram.webhookSecret = webhookSecret || null;
|
||||
const webhookPath = state.telegramForm.webhookPath.trim();
|
||||
telegram.webhookPath = webhookPath || null;
|
||||
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.telegramConfigStatus = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
const raw = `${JSON.stringify(
|
||||
{ channels: { telegram } },
|
||||
null,
|
||||
2,
|
||||
).trimEnd()}\n`;
|
||||
await state.client.request("config.patch", { raw, baseHash });
|
||||
state.telegramConfigStatus = "Saved. Restart gateway if needed.";
|
||||
} catch (err) {
|
||||
state.telegramConfigStatus = String(err);
|
||||
} finally {
|
||||
state.telegramSaving = false;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import type { ChannelsStatusSnapshot, ConfigSnapshot } from "../types";
|
||||
import type {
|
||||
DiscordForm,
|
||||
IMessageForm,
|
||||
SlackForm,
|
||||
SignalForm,
|
||||
TelegramForm,
|
||||
} from "../ui-types";
|
||||
|
||||
export type ConnectionsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
channelsLoading: boolean;
|
||||
channelsSnapshot: ChannelsStatusSnapshot | null;
|
||||
channelsError: string | null;
|
||||
channelsLastSuccess: number | null;
|
||||
whatsappLoginMessage: string | null;
|
||||
whatsappLoginQrDataUrl: string | null;
|
||||
whatsappLoginConnected: boolean | null;
|
||||
whatsappBusy: boolean;
|
||||
telegramForm: TelegramForm;
|
||||
telegramSaving: boolean;
|
||||
telegramTokenLocked: boolean;
|
||||
telegramConfigStatus: string | null;
|
||||
discordForm: DiscordForm;
|
||||
discordSaving: boolean;
|
||||
discordTokenLocked: boolean;
|
||||
discordConfigStatus: string | null;
|
||||
slackForm: SlackForm;
|
||||
slackSaving: boolean;
|
||||
slackTokenLocked: boolean;
|
||||
slackAppTokenLocked: boolean;
|
||||
slackConfigStatus: string | null;
|
||||
signalForm: SignalForm;
|
||||
signalSaving: boolean;
|
||||
signalConfigStatus: string | null;
|
||||
imessageForm: IMessageForm;
|
||||
imessageSaving: boolean;
|
||||
imessageConfigStatus: string | null;
|
||||
configSnapshot: ConfigSnapshot | null;
|
||||
};
|
||||
|
||||
@@ -45,14 +45,14 @@ describe("chat focus mode", () => {
|
||||
await app.updateComplete;
|
||||
expect(shell?.classList.contains("shell--chat-focus")).toBe(true);
|
||||
|
||||
const link = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/connections"]');
|
||||
const link = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/channels"]');
|
||||
expect(link).not.toBeNull();
|
||||
link?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
|
||||
);
|
||||
|
||||
await app.updateComplete;
|
||||
expect(app.tab).toBe("connections");
|
||||
expect(app.tab).toBe("channels");
|
||||
expect(shell?.classList.contains("shell--chat-focus")).toBe(false);
|
||||
|
||||
const chatLink = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/chat"]');
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("control UI routing", () => {
|
||||
await app.updateComplete;
|
||||
|
||||
const link = app.querySelector<HTMLAnchorElement>(
|
||||
'a.nav-item[href="/connections"]',
|
||||
'a.nav-item[href="/channels"]',
|
||||
);
|
||||
expect(link).not.toBeNull();
|
||||
link?.dispatchEvent(
|
||||
@@ -84,8 +84,8 @@ describe("control UI routing", () => {
|
||||
);
|
||||
|
||||
await app.updateComplete;
|
||||
expect(app.tab).toBe("connections");
|
||||
expect(window.location.pathname).toBe("/connections");
|
||||
expect(app.tab).toBe("channels");
|
||||
expect(window.location.pathname).toBe("/channels");
|
||||
});
|
||||
|
||||
it("keeps chat and nav usable on narrow viewports", async () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("iconForTab", () => {
|
||||
it("returns stable icons for known tabs", () => {
|
||||
expect(iconForTab("chat")).toBe("💬");
|
||||
expect(iconForTab("overview")).toBe("📊");
|
||||
expect(iconForTab("connections")).toBe("🔗");
|
||||
expect(iconForTab("channels")).toBe("🔗");
|
||||
expect(iconForTab("instances")).toBe("📡");
|
||||
expect(iconForTab("sessions")).toBe("📄");
|
||||
expect(iconForTab("cron")).toBe("⏰");
|
||||
|
||||
@@ -2,7 +2,7 @@ export const TAB_GROUPS = [
|
||||
{ label: "Chat", tabs: ["chat"] },
|
||||
{
|
||||
label: "Control",
|
||||
tabs: ["overview", "connections", "instances", "sessions", "cron"],
|
||||
tabs: ["overview", "channels", "instances", "sessions", "cron"],
|
||||
},
|
||||
{ label: "Agent", tabs: ["skills", "nodes"] },
|
||||
{ label: "Settings", tabs: ["config", "debug", "logs"] },
|
||||
@@ -10,7 +10,7 @@ export const TAB_GROUPS = [
|
||||
|
||||
export type Tab =
|
||||
| "overview"
|
||||
| "connections"
|
||||
| "channels"
|
||||
| "instances"
|
||||
| "sessions"
|
||||
| "cron"
|
||||
@@ -23,7 +23,7 @@ export type Tab =
|
||||
|
||||
const TAB_PATHS: Record<Tab, string> = {
|
||||
overview: "/overview",
|
||||
connections: "/connections",
|
||||
channels: "/channels",
|
||||
instances: "/instances",
|
||||
sessions: "/sessions",
|
||||
cron: "/cron",
|
||||
@@ -104,7 +104,7 @@ export function iconForTab(tab: Tab): string {
|
||||
return "💬";
|
||||
case "overview":
|
||||
return "📊";
|
||||
case "connections":
|
||||
case "channels":
|
||||
return "🔗";
|
||||
case "instances":
|
||||
return "📡";
|
||||
@@ -131,8 +131,8 @@ export function titleForTab(tab: Tab) {
|
||||
switch (tab) {
|
||||
case "overview":
|
||||
return "Overview";
|
||||
case "connections":
|
||||
return "Connections";
|
||||
case "channels":
|
||||
return "Channels";
|
||||
case "instances":
|
||||
return "Instances";
|
||||
case "sessions":
|
||||
@@ -160,8 +160,8 @@ export function subtitleForTab(tab: Tab) {
|
||||
switch (tab) {
|
||||
case "overview":
|
||||
return "Gateway status, entry points, and a fast health read.";
|
||||
case "connections":
|
||||
return "Link channels and keep transport settings in sync.";
|
||||
case "channels":
|
||||
return "Manage channels and settings.";
|
||||
case "instances":
|
||||
return "Presence beacons from connected clients and nodes.";
|
||||
case "sessions":
|
||||
|
||||
@@ -1,159 +1,9 @@
|
||||
export type TelegramForm = {
|
||||
token: string;
|
||||
requireMention: boolean;
|
||||
groupsWildcardEnabled: boolean;
|
||||
allowFrom: string;
|
||||
proxy: string;
|
||||
webhookUrl: string;
|
||||
webhookSecret: string;
|
||||
webhookPath: string;
|
||||
};
|
||||
|
||||
export type ChatQueueItem = {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type DiscordForm = {
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
dmEnabled: boolean;
|
||||
allowFrom: string;
|
||||
groupEnabled: boolean;
|
||||
groupChannels: string;
|
||||
mediaMaxMb: string;
|
||||
historyLimit: string;
|
||||
textChunkLimit: string;
|
||||
replyToMode: "off" | "first" | "all";
|
||||
guilds: DiscordGuildForm[];
|
||||
actions: DiscordActionForm;
|
||||
slashEnabled: boolean;
|
||||
slashName: string;
|
||||
slashSessionPrefix: string;
|
||||
slashEphemeral: boolean;
|
||||
};
|
||||
|
||||
export type DiscordGuildForm = {
|
||||
key: string;
|
||||
slug: string;
|
||||
requireMention: boolean;
|
||||
reactionNotifications: "off" | "own" | "all" | "allowlist";
|
||||
users: string;
|
||||
channels: DiscordGuildChannelForm[];
|
||||
};
|
||||
|
||||
export type DiscordGuildChannelForm = {
|
||||
key: string;
|
||||
allow: boolean;
|
||||
requireMention: boolean;
|
||||
};
|
||||
|
||||
export type DiscordActionForm = {
|
||||
reactions: boolean;
|
||||
stickers: boolean;
|
||||
polls: boolean;
|
||||
permissions: boolean;
|
||||
messages: boolean;
|
||||
threads: boolean;
|
||||
pins: boolean;
|
||||
search: boolean;
|
||||
memberInfo: boolean;
|
||||
roleInfo: boolean;
|
||||
channelInfo: boolean;
|
||||
voiceStatus: boolean;
|
||||
events: boolean;
|
||||
roles: boolean;
|
||||
moderation: boolean;
|
||||
};
|
||||
|
||||
export type SlackChannelForm = {
|
||||
key: string;
|
||||
allow: boolean;
|
||||
requireMention: boolean;
|
||||
};
|
||||
|
||||
export type SlackActionForm = {
|
||||
reactions: boolean;
|
||||
messages: boolean;
|
||||
pins: boolean;
|
||||
memberInfo: boolean;
|
||||
emojiList: boolean;
|
||||
};
|
||||
|
||||
export type SlackForm = {
|
||||
enabled: boolean;
|
||||
botToken: string;
|
||||
appToken: string;
|
||||
dmEnabled: boolean;
|
||||
allowFrom: string;
|
||||
groupEnabled: boolean;
|
||||
groupChannels: string;
|
||||
mediaMaxMb: string;
|
||||
textChunkLimit: string;
|
||||
reactionNotifications: "off" | "own" | "all" | "allowlist";
|
||||
reactionAllowlist: string;
|
||||
slashEnabled: boolean;
|
||||
slashName: string;
|
||||
slashSessionPrefix: string;
|
||||
slashEphemeral: boolean;
|
||||
actions: SlackActionForm;
|
||||
channels: SlackChannelForm[];
|
||||
};
|
||||
|
||||
export const defaultDiscordActions: DiscordActionForm = {
|
||||
reactions: true,
|
||||
stickers: true,
|
||||
polls: true,
|
||||
permissions: true,
|
||||
messages: true,
|
||||
threads: true,
|
||||
pins: true,
|
||||
search: true,
|
||||
memberInfo: true,
|
||||
roleInfo: true,
|
||||
channelInfo: true,
|
||||
voiceStatus: true,
|
||||
events: true,
|
||||
roles: false,
|
||||
moderation: false,
|
||||
};
|
||||
|
||||
export const defaultSlackActions: SlackActionForm = {
|
||||
reactions: true,
|
||||
messages: true,
|
||||
pins: true,
|
||||
memberInfo: true,
|
||||
emojiList: true,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
134
ui/src/ui/views/channels.config.ts
Normal file
134
ui/src/ui/views/channels.config.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { html } from "lit";
|
||||
|
||||
import type { ConfigUiHints } from "../types";
|
||||
import type { ChannelsProps } from "./channels.types";
|
||||
import {
|
||||
analyzeConfigSchema,
|
||||
renderNode,
|
||||
schemaType,
|
||||
type JsonSchema,
|
||||
} from "./config-form";
|
||||
|
||||
type ChannelConfigFormProps = {
|
||||
channelId: string;
|
||||
configValue: Record<string, unknown> | null;
|
||||
schema: unknown | null;
|
||||
uiHints: ConfigUiHints;
|
||||
disabled: boolean;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
};
|
||||
|
||||
function resolveSchemaNode(
|
||||
schema: JsonSchema | null,
|
||||
path: Array<string | number>,
|
||||
): JsonSchema | null {
|
||||
let current = schema;
|
||||
for (const key of path) {
|
||||
if (!current) return null;
|
||||
const type = schemaType(current);
|
||||
if (type === "object") {
|
||||
const properties = current.properties ?? {};
|
||||
if (typeof key === "string" && properties[key]) {
|
||||
current = properties[key];
|
||||
continue;
|
||||
}
|
||||
const additional = current.additionalProperties;
|
||||
if (typeof key === "string" && additional && typeof additional === "object") {
|
||||
current = additional as JsonSchema;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (type === "array") {
|
||||
if (typeof key !== "number") return null;
|
||||
const items = Array.isArray(current.items) ? current.items[0] : current.items;
|
||||
current = items ?? null;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function resolveChannelValue(
|
||||
config: Record<string, unknown>,
|
||||
channelId: string,
|
||||
): Record<string, unknown> {
|
||||
const channels = (config.channels ?? {}) as Record<string, unknown>;
|
||||
const fromChannels = channels[channelId];
|
||||
const fallback = config[channelId];
|
||||
const resolved =
|
||||
(fromChannels && typeof fromChannels === "object"
|
||||
? (fromChannels as Record<string, unknown>)
|
||||
: null) ??
|
||||
(fallback && typeof fallback === "object"
|
||||
? (fallback as Record<string, unknown>)
|
||||
: null);
|
||||
return resolved ?? {};
|
||||
}
|
||||
|
||||
export function renderChannelConfigForm(props: ChannelConfigFormProps) {
|
||||
const analysis = analyzeConfigSchema(props.schema);
|
||||
const normalized = analysis.schema;
|
||||
if (!normalized) {
|
||||
return html`<div class="callout danger">Schema unavailable. Use Raw.</div>`;
|
||||
}
|
||||
const node = resolveSchemaNode(normalized, ["channels", props.channelId]);
|
||||
if (!node) {
|
||||
return html`<div class="callout danger">Channel config schema unavailable.</div>`;
|
||||
}
|
||||
const configValue = props.configValue ?? {};
|
||||
const value = resolveChannelValue(configValue, props.channelId);
|
||||
return html`
|
||||
<div class="config-form">
|
||||
${renderNode({
|
||||
schema: node,
|
||||
value,
|
||||
path: ["channels", props.channelId],
|
||||
hints: props.uiHints,
|
||||
unsupported: new Set(analysis.unsupportedPaths),
|
||||
disabled: props.disabled,
|
||||
showLabel: false,
|
||||
onPatch: props.onPatch,
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderChannelConfigSection(params: {
|
||||
channelId: string;
|
||||
props: ChannelsProps;
|
||||
}) {
|
||||
const { channelId, props } = params;
|
||||
const disabled = props.configSaving || props.configSchemaLoading;
|
||||
return html`
|
||||
<div style="margin-top: 16px;">
|
||||
${props.configSchemaLoading
|
||||
? html`<div class="muted">Loading config schema…</div>`
|
||||
: renderChannelConfigForm({
|
||||
channelId,
|
||||
configValue: props.configForm,
|
||||
schema: props.configSchema,
|
||||
uiHints: props.configUiHints,
|
||||
disabled,
|
||||
onPatch: props.onConfigPatch,
|
||||
})}
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${disabled || !props.configFormDirty}
|
||||
@click=${() => props.onConfigSave()}
|
||||
>
|
||||
${props.configSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${disabled}
|
||||
@click=${() => props.onConfigReload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
62
ui/src/ui/views/channels.discord.ts
Normal file
62
ui/src/ui/views/channels.discord.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { DiscordStatus } from "../types";
|
||||
import type { ChannelsProps } from "./channels.types";
|
||||
import { renderChannelConfigSection } from "./channels.config";
|
||||
|
||||
export function renderDiscordCard(params: {
|
||||
props: ChannelsProps;
|
||||
discord?: DiscordStatus | null;
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, discord, accountCountLabel } = params;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Discord</div>
|
||||
<div class="card-sub">Bot status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${discord?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${discord?.running ? "Yes" : "No"}</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;">
|
||||
${discord.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${discord?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${discord.probe.ok ? "ok" : "failed"} ·
|
||||
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "discord", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
62
ui/src/ui/views/channels.imessage.ts
Normal file
62
ui/src/ui/views/channels.imessage.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { IMessageStatus } from "../types";
|
||||
import type { ChannelsProps } from "./channels.types";
|
||||
import { renderChannelConfigSection } from "./channels.config";
|
||||
|
||||
export function renderIMessageCard(params: {
|
||||
props: ChannelsProps;
|
||||
imessage?: IMessageStatus | null;
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, imessage, accountCountLabel } = params;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">iMessage</div>
|
||||
<div class="card-sub">macOS bridge status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<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">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
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
|
||||
${imessage.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "imessage", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
46
ui/src/ui/views/channels.shared.ts
Normal file
46
ui/src/ui/views/channels.shared.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import type { ChannelAccountSnapshot } from "../types";
|
||||
import type { ChannelKey, ChannelsProps } from "./channels.types";
|
||||
|
||||
export 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`;
|
||||
}
|
||||
|
||||
export function channelEnabled(key: ChannelKey, props: ChannelsProps) {
|
||||
const snapshot = props.snapshot;
|
||||
const channels = snapshot?.channels as Record<string, unknown> | null;
|
||||
if (!snapshot || !channels) return false;
|
||||
const channelStatus = channels[key] as Record<string, unknown> | undefined;
|
||||
const configured = typeof channelStatus?.configured === "boolean" && channelStatus.configured;
|
||||
const running = typeof channelStatus?.running === "boolean" && channelStatus.running;
|
||||
const connected = typeof channelStatus?.connected === "boolean" && channelStatus.connected;
|
||||
const accounts = snapshot.channelAccounts?.[key] ?? [];
|
||||
const accountActive = accounts.some(
|
||||
(account) => account.configured || account.running || account.connected,
|
||||
);
|
||||
return configured || running || connected || accountActive;
|
||||
}
|
||||
|
||||
export function getChannelAccountCount(
|
||||
key: ChannelKey,
|
||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
|
||||
): number {
|
||||
return channelAccounts?.[key]?.length ?? 0;
|
||||
}
|
||||
|
||||
export function renderChannelAccountCount(
|
||||
key: ChannelKey,
|
||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
|
||||
) {
|
||||
const count = getChannelAccountCount(key, channelAccounts);
|
||||
if (count < 2) return nothing;
|
||||
return html`<div class="account-count">Accounts (${count})</div>`;
|
||||
}
|
||||
|
||||
66
ui/src/ui/views/channels.signal.ts
Normal file
66
ui/src/ui/views/channels.signal.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { SignalStatus } from "../types";
|
||||
import type { ChannelsProps } from "./channels.types";
|
||||
import { renderChannelConfigSection } from "./channels.config";
|
||||
|
||||
export function renderSignalCard(params: {
|
||||
props: ChannelsProps;
|
||||
signal?: SignalStatus | null;
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, signal, accountCountLabel } = params;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Signal</div>
|
||||
<div class="card-sub">signal-cli status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<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}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "signal", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
62
ui/src/ui/views/channels.slack.ts
Normal file
62
ui/src/ui/views/channels.slack.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { SlackStatus } from "../types";
|
||||
import type { ChannelsProps } from "./channels.types";
|
||||
import { renderChannelConfigSection } from "./channels.config";
|
||||
|
||||
export function renderSlackCard(params: {
|
||||
props: ChannelsProps;
|
||||
slack?: SlackStatus | null;
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, slack, accountCountLabel } = params;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Slack</div>
|
||||
<div class="card-sub">Socket mode status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${slack?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${slack?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${slack?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${slack.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${slack?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${slack.probe.ok ? "ok" : "failed"} ·
|
||||
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "slack", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
113
ui/src/ui/views/channels.telegram.ts
Normal file
113
ui/src/ui/views/channels.telegram.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { ChannelAccountSnapshot, TelegramStatus } from "../types";
|
||||
import type { ChannelsProps } from "./channels.types";
|
||||
import { renderChannelConfigSection } from "./channels.config";
|
||||
|
||||
export function renderTelegramCard(params: {
|
||||
props: ChannelsProps;
|
||||
telegram?: TelegramStatus;
|
||||
telegramAccounts: ChannelAccountSnapshot[];
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, telegram, telegramAccounts, accountCountLabel } = params;
|
||||
const hasMultipleAccounts = telegramAccounts.length > 1;
|
||||
|
||||
const renderAccountCard = (account: ChannelAccountSnapshot) => {
|
||||
const probe = account.probe as { bot?: { username?: string } } | undefined;
|
||||
const botUsername = probe?.bot?.username;
|
||||
const label = account.name || account.accountId;
|
||||
return html`
|
||||
<div class="account-card">
|
||||
<div class="account-card-header">
|
||||
<div class="account-card-title">
|
||||
${botUsername ? `@${botUsername}` : label}
|
||||
</div>
|
||||
<div class="account-card-id">${account.accountId}</div>
|
||||
</div>
|
||||
<div class="status-list account-card-status">
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${account.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${account.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last inbound</span>
|
||||
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
|
||||
</div>
|
||||
${account.lastError
|
||||
? html`
|
||||
<div class="account-card-error">
|
||||
${account.lastError}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Telegram</div>
|
||||
<div class="card-sub">Bot status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
${hasMultipleAccounts
|
||||
? html`
|
||||
<div class="account-card-list">
|
||||
${telegramAccounts.map((account) => renderAccountCard(account))}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${telegram?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${telegram?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Mode</span>
|
||||
<span>${telegram?.mode ?? "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${telegram?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${telegram.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${telegram?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
|
||||
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "telegram", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
234
ui/src/ui/views/channels.ts
Normal file
234
ui/src/ui/views/channels.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelsStatusSnapshot,
|
||||
DiscordStatus,
|
||||
IMessageStatus,
|
||||
SignalStatus,
|
||||
SlackStatus,
|
||||
TelegramStatus,
|
||||
WhatsAppStatus,
|
||||
} from "../types";
|
||||
import type {
|
||||
ChannelKey,
|
||||
ChannelsChannelData,
|
||||
ChannelsProps,
|
||||
} from "./channels.types";
|
||||
import { channelEnabled, renderChannelAccountCount } from "./channels.shared";
|
||||
import { renderChannelConfigSection } from "./channels.config";
|
||||
import { renderDiscordCard } from "./channels.discord";
|
||||
import { renderIMessageCard } from "./channels.imessage";
|
||||
import { renderSignalCard } from "./channels.signal";
|
||||
import { renderSlackCard } from "./channels.slack";
|
||||
import { renderTelegramCard } from "./channels.telegram";
|
||||
import { renderWhatsAppCard } from "./channels.whatsapp";
|
||||
|
||||
export function renderChannels(props: ChannelsProps) {
|
||||
const channels = props.snapshot?.channels as Record<string, unknown> | null;
|
||||
const whatsapp = (channels?.whatsapp ?? undefined) as
|
||||
| WhatsAppStatus
|
||||
| undefined;
|
||||
const telegram = (channels?.telegram ?? undefined) as
|
||||
| TelegramStatus
|
||||
| undefined;
|
||||
const discord = (channels?.discord ?? null) as DiscordStatus | null;
|
||||
const slack = (channels?.slack ?? null) as SlackStatus | null;
|
||||
const signal = (channels?.signal ?? null) as SignalStatus | null;
|
||||
const imessage = (channels?.imessage ?? null) as IMessageStatus | null;
|
||||
const channelOrder = resolveChannelOrder(props.snapshot);
|
||||
const orderedChannels = channelOrder
|
||||
.map((key, index) => ({
|
||||
key,
|
||||
enabled: channelEnabled(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">
|
||||
${orderedChannels.map((channel) =>
|
||||
renderChannel(channel.key, props, {
|
||||
whatsapp,
|
||||
telegram,
|
||||
discord,
|
||||
slack,
|
||||
signal,
|
||||
imessage,
|
||||
channelAccounts: props.snapshot?.channelAccounts ?? null,
|
||||
}),
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 18px;">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
<div class="card-title">Channel health</div>
|
||||
<div class="card-sub">Channel 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 resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKey[] {
|
||||
if (snapshot?.channelOrder?.length) {
|
||||
return snapshot.channelOrder;
|
||||
}
|
||||
return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"];
|
||||
}
|
||||
|
||||
function renderChannel(
|
||||
key: ChannelKey,
|
||||
props: ChannelsProps,
|
||||
data: ChannelsChannelData,
|
||||
) {
|
||||
const accountCountLabel = renderChannelAccountCount(
|
||||
key,
|
||||
data.channelAccounts,
|
||||
);
|
||||
switch (key) {
|
||||
case "whatsapp":
|
||||
return renderWhatsAppCard({
|
||||
props,
|
||||
whatsapp: data.whatsapp,
|
||||
accountCountLabel,
|
||||
});
|
||||
case "telegram":
|
||||
return renderTelegramCard({
|
||||
props,
|
||||
telegram: data.telegram,
|
||||
telegramAccounts: data.channelAccounts?.telegram ?? [],
|
||||
accountCountLabel,
|
||||
});
|
||||
case "discord":
|
||||
return renderDiscordCard({
|
||||
props,
|
||||
discord: data.discord,
|
||||
accountCountLabel,
|
||||
});
|
||||
case "slack":
|
||||
return renderSlackCard({
|
||||
props,
|
||||
slack: data.slack,
|
||||
accountCountLabel,
|
||||
});
|
||||
case "signal":
|
||||
return renderSignalCard({
|
||||
props,
|
||||
signal: data.signal,
|
||||
accountCountLabel,
|
||||
});
|
||||
case "imessage":
|
||||
return renderIMessageCard({
|
||||
props,
|
||||
imessage: data.imessage,
|
||||
accountCountLabel,
|
||||
});
|
||||
default:
|
||||
return renderGenericChannelCard(key, props, data.channelAccounts ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
function renderGenericChannelCard(
|
||||
key: ChannelKey,
|
||||
props: ChannelsProps,
|
||||
channelAccounts: Record<string, ChannelAccountSnapshot[]>,
|
||||
) {
|
||||
const label = props.snapshot?.channelLabels?.[key] ?? key;
|
||||
const status = props.snapshot?.channels?.[key] as Record<string, unknown> | undefined;
|
||||
const configured = typeof status?.configured === "boolean" ? status.configured : undefined;
|
||||
const running = typeof status?.running === "boolean" ? status.running : undefined;
|
||||
const connected = typeof status?.connected === "boolean" ? status.connected : undefined;
|
||||
const lastError = typeof status?.lastError === "string" ? status.lastError : undefined;
|
||||
const accounts = channelAccounts[key] ?? [];
|
||||
const accountCountLabel = renderChannelAccountCount(key, channelAccounts);
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">${label}</div>
|
||||
<div class="card-sub">Channel status and configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
${accounts.length > 0
|
||||
? html`
|
||||
<div class="account-card-list">
|
||||
${accounts.map((account) => renderGenericAccount(account))}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${configured == null ? "n/a" : configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${running == null ? "n/a" : running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Connected</span>
|
||||
<span>${connected == null ? "n/a" : connected ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: key, props })}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGenericAccount(account: ChannelAccountSnapshot) {
|
||||
return html`
|
||||
<div class="account-card">
|
||||
<div class="account-card-header">
|
||||
<div class="account-card-title">${account.name || account.accountId}</div>
|
||||
<div class="account-card-id">${account.accountId}</div>
|
||||
</div>
|
||||
<div class="status-list account-card-status">
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${account.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${account.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Connected</span>
|
||||
<span>${account.connected ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last inbound</span>
|
||||
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
|
||||
</div>
|
||||
${account.lastError
|
||||
? html`
|
||||
<div class="account-card-error">
|
||||
${account.lastError}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
48
ui/src/ui/views/channels.types.ts
Normal file
48
ui/src/ui/views/channels.types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelsStatusSnapshot,
|
||||
ConfigUiHints,
|
||||
DiscordStatus,
|
||||
IMessageStatus,
|
||||
SignalStatus,
|
||||
SlackStatus,
|
||||
TelegramStatus,
|
||||
WhatsAppStatus,
|
||||
} from "../types";
|
||||
|
||||
export type ChannelKey = string;
|
||||
|
||||
export type ChannelsProps = {
|
||||
connected: boolean;
|
||||
loading: boolean;
|
||||
snapshot: ChannelsStatusSnapshot | null;
|
||||
lastError: string | null;
|
||||
lastSuccessAt: number | null;
|
||||
whatsappMessage: string | null;
|
||||
whatsappQrDataUrl: string | null;
|
||||
whatsappConnected: boolean | null;
|
||||
whatsappBusy: boolean;
|
||||
configSchema: unknown | null;
|
||||
configSchemaLoading: boolean;
|
||||
configForm: Record<string, unknown> | null;
|
||||
configUiHints: ConfigUiHints;
|
||||
configSaving: boolean;
|
||||
configFormDirty: boolean;
|
||||
onRefresh: (probe: boolean) => void;
|
||||
onWhatsAppStart: (force: boolean) => void;
|
||||
onWhatsAppWait: () => void;
|
||||
onWhatsAppLogout: () => void;
|
||||
onConfigPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
onConfigSave: () => void;
|
||||
onConfigReload: () => void;
|
||||
};
|
||||
|
||||
export type ChannelsChannelData = {
|
||||
whatsapp?: WhatsAppStatus;
|
||||
telegram?: TelegramStatus;
|
||||
discord?: DiscordStatus | null;
|
||||
slack?: SlackStatus | null;
|
||||
signal?: SignalStatus | null;
|
||||
imessage?: IMessageStatus | null;
|
||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null;
|
||||
};
|
||||
@@ -2,11 +2,12 @@ import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { WhatsAppStatus } from "../types";
|
||||
import type { ConnectionsProps } from "./connections.types";
|
||||
import { formatDuration } from "./connections.shared";
|
||||
import type { ChannelsProps } from "./channels.types";
|
||||
import { renderChannelConfigSection } from "./channels.config";
|
||||
import { formatDuration } from "./channels.shared";
|
||||
|
||||
export function renderWhatsAppCard(params: {
|
||||
props: ConnectionsProps;
|
||||
props: ChannelsProps;
|
||||
whatsapp?: WhatsAppStatus;
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
@@ -110,6 +111,8 @@ export function renderWhatsAppCard(params: {
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${renderChannelConfigSection({ channelId: "whatsapp", props })}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -3,5 +3,6 @@ export {
|
||||
analyzeConfigSchema,
|
||||
type ConfigSchemaAnalysis,
|
||||
} from "./config-form.analyze";
|
||||
export type { JsonSchema } from "./config-form.shared";
|
||||
export { renderNode } from "./config-form.node";
|
||||
export { schemaType, type JsonSchema } from "./config-form.shared";
|
||||
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import {
|
||||
MOONSHOT_KIMI_K2_CONTEXT_WINDOW,
|
||||
MOONSHOT_KIMI_K2_COST,
|
||||
MOONSHOT_KIMI_K2_DEFAULT_ID,
|
||||
MOONSHOT_KIMI_K2_INPUT,
|
||||
MOONSHOT_KIMI_K2_MAX_TOKENS,
|
||||
MOONSHOT_KIMI_K2_MODELS,
|
||||
} from "../data/moonshot-kimi-k2";
|
||||
import type { ConfigUiHints } from "../types";
|
||||
import { analyzeConfigSchema, renderConfigForm } from "./config-form";
|
||||
|
||||
type ConfigPatch = {
|
||||
path: Array<string | number>;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export type ConfigProps = {
|
||||
raw: string;
|
||||
valid: boolean | null;
|
||||
@@ -38,287 +25,6 @@ export type ConfigProps = {
|
||||
onUpdate: () => void;
|
||||
};
|
||||
|
||||
function cloneConfigObject<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") return structuredClone(value);
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function tryParseJsonObject(raw: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setPathValue(
|
||||
obj: Record<string, unknown> | unknown[],
|
||||
path: Array<string | number>,
|
||||
value: unknown,
|
||||
) {
|
||||
if (path.length === 0) return;
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const key = path[i];
|
||||
const nextKey = path[i + 1];
|
||||
if (typeof key === "number") {
|
||||
if (!Array.isArray(current)) return;
|
||||
if (current[key] == null) {
|
||||
current[key] =
|
||||
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
|
||||
}
|
||||
current = current[key] as Record<string, unknown> | unknown[];
|
||||
} else {
|
||||
if (typeof current !== "object" || current == null) return;
|
||||
const record = current as Record<string, unknown>;
|
||||
if (record[key] == null) {
|
||||
record[key] =
|
||||
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
|
||||
}
|
||||
current = record[key] as Record<string, unknown> | unknown[];
|
||||
}
|
||||
}
|
||||
const lastKey = path[path.length - 1];
|
||||
if (typeof lastKey === "number") {
|
||||
if (Array.isArray(current)) current[lastKey] = value;
|
||||
return;
|
||||
}
|
||||
if (typeof current === "object" && current != null) {
|
||||
(current as Record<string, unknown>)[lastKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function getPathValue(
|
||||
obj: unknown,
|
||||
path: Array<string | number>,
|
||||
): unknown | undefined {
|
||||
let current: unknown = obj;
|
||||
for (const key of path) {
|
||||
if (typeof key === "number") {
|
||||
if (!Array.isArray(current)) return undefined;
|
||||
current = current[key];
|
||||
} else {
|
||||
if (!current || typeof current !== "object") return undefined;
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function buildModelPresetPatches(base: Record<string, unknown>): Array<{
|
||||
id: "minimax" | "zai" | "moonshot";
|
||||
title: string;
|
||||
description: string;
|
||||
patches: ConfigPatch[];
|
||||
}> {
|
||||
const setPrimary = (modelRef: string) => ({
|
||||
path: ["agents", "defaults", "model", "primary"],
|
||||
value: modelRef,
|
||||
});
|
||||
const safeAlias = (modelRef: string, alias: string): ConfigPatch | null => {
|
||||
const existingAlias = getPathValue(base, [
|
||||
"agents",
|
||||
"defaults",
|
||||
"models",
|
||||
modelRef,
|
||||
"alias",
|
||||
]);
|
||||
if (typeof existingAlias === "string" && existingAlias.trim().length > 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
path: ["agents", "defaults", "models", modelRef, "alias"],
|
||||
value: alias,
|
||||
};
|
||||
};
|
||||
|
||||
const minimaxModelsPath = ["models", "providers", "minimax", "models"] satisfies Array<
|
||||
string | number
|
||||
>;
|
||||
const moonshotModelsPath = [
|
||||
"models",
|
||||
"providers",
|
||||
"moonshot",
|
||||
"models",
|
||||
] satisfies Array<string | number>;
|
||||
|
||||
const hasNonEmptyString = (value: unknown) =>
|
||||
typeof value === "string" && value.trim().length > 0;
|
||||
|
||||
const envMinimax = getPathValue(base, ["env", "MINIMAX_API_KEY"]);
|
||||
const envZai = getPathValue(base, ["env", "ZAI_API_KEY"]);
|
||||
const envMoonshot = getPathValue(base, ["env", "MOONSHOT_API_KEY"]);
|
||||
|
||||
const minimaxHasModels = Array.isArray(getPathValue(base, minimaxModelsPath));
|
||||
const moonshotHasModels = Array.isArray(getPathValue(base, moonshotModelsPath));
|
||||
|
||||
const minimaxProviderBaseUrl = getPathValue(base, [
|
||||
"models",
|
||||
"providers",
|
||||
"minimax",
|
||||
"baseUrl",
|
||||
]);
|
||||
const minimaxProviderApiKey = getPathValue(base, [
|
||||
"models",
|
||||
"providers",
|
||||
"minimax",
|
||||
"apiKey",
|
||||
]);
|
||||
const minimaxProviderApi = getPathValue(base, [
|
||||
"models",
|
||||
"providers",
|
||||
"minimax",
|
||||
"api",
|
||||
]);
|
||||
const moonshotProviderBaseUrl = getPathValue(base, [
|
||||
"models",
|
||||
"providers",
|
||||
"moonshot",
|
||||
"baseUrl",
|
||||
]);
|
||||
const moonshotProviderApiKey = getPathValue(base, [
|
||||
"models",
|
||||
"providers",
|
||||
"moonshot",
|
||||
"apiKey",
|
||||
]);
|
||||
const moonshotProviderApi = getPathValue(base, [
|
||||
"models",
|
||||
"providers",
|
||||
"moonshot",
|
||||
"api",
|
||||
]);
|
||||
const modelsMode = getPathValue(base, ["models", "mode"]);
|
||||
|
||||
const minimax: ConfigPatch[] = [];
|
||||
if (!hasNonEmptyString(envMinimax)) {
|
||||
minimax.push({ path: ["env", "MINIMAX_API_KEY"], value: "sk-..." });
|
||||
}
|
||||
if (modelsMode == null) {
|
||||
minimax.push({ path: ["models", "mode"], value: "merge" });
|
||||
}
|
||||
// Intentional: enforce the preferred MiniMax endpoint/mode.
|
||||
if (minimaxProviderBaseUrl !== "https://api.minimax.io/anthropic") {
|
||||
minimax.push({
|
||||
path: ["models", "providers", "minimax", "baseUrl"],
|
||||
value: "https://api.minimax.io/anthropic",
|
||||
});
|
||||
}
|
||||
if (!hasNonEmptyString(minimaxProviderApiKey)) {
|
||||
minimax.push({
|
||||
path: ["models", "providers", "minimax", "apiKey"],
|
||||
value: "${MINIMAX_API_KEY}",
|
||||
});
|
||||
}
|
||||
if (minimaxProviderApi !== "anthropic-messages") {
|
||||
minimax.push({
|
||||
path: ["models", "providers", "minimax", "api"],
|
||||
value: "anthropic-messages",
|
||||
});
|
||||
}
|
||||
if (!minimaxHasModels) {
|
||||
minimax.push({
|
||||
path: minimaxModelsPath as Array<string | number>,
|
||||
value: [
|
||||
{
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
minimax.push(setPrimary("minimax/MiniMax-M2.1"));
|
||||
const minimaxAlias = safeAlias("minimax/MiniMax-M2.1", "Minimax");
|
||||
if (minimaxAlias) minimax.push(minimaxAlias);
|
||||
|
||||
const zai: ConfigPatch[] = [];
|
||||
if (!hasNonEmptyString(envZai)) {
|
||||
zai.push({ path: ["env", "ZAI_API_KEY"], value: "sk-..." });
|
||||
}
|
||||
zai.push(setPrimary("zai/glm-4.7"));
|
||||
const zaiAlias = safeAlias("zai/glm-4.7", "GLM 4.7");
|
||||
if (zaiAlias) zai.push(zaiAlias);
|
||||
|
||||
const moonshot: ConfigPatch[] = [];
|
||||
if (!hasNonEmptyString(envMoonshot)) {
|
||||
moonshot.push({ path: ["env", "MOONSHOT_API_KEY"], value: "sk-..." });
|
||||
}
|
||||
if (modelsMode == null) {
|
||||
moonshot.push({ path: ["models", "mode"], value: "merge" });
|
||||
}
|
||||
if (!hasNonEmptyString(moonshotProviderBaseUrl)) {
|
||||
moonshot.push({
|
||||
path: ["models", "providers", "moonshot", "baseUrl"],
|
||||
value: "https://api.moonshot.ai/v1",
|
||||
});
|
||||
}
|
||||
if (!hasNonEmptyString(moonshotProviderApiKey)) {
|
||||
moonshot.push({
|
||||
path: ["models", "providers", "moonshot", "apiKey"],
|
||||
value: "${MOONSHOT_API_KEY}",
|
||||
});
|
||||
}
|
||||
if (!hasNonEmptyString(moonshotProviderApi)) {
|
||||
moonshot.push({
|
||||
path: ["models", "providers", "moonshot", "api"],
|
||||
value: "openai-completions",
|
||||
});
|
||||
}
|
||||
const moonshotModelDefinitions = MOONSHOT_KIMI_K2_MODELS.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
reasoning: model.reasoning,
|
||||
input: [...MOONSHOT_KIMI_K2_INPUT],
|
||||
cost: { ...MOONSHOT_KIMI_K2_COST },
|
||||
contextWindow: MOONSHOT_KIMI_K2_CONTEXT_WINDOW,
|
||||
maxTokens: MOONSHOT_KIMI_K2_MAX_TOKENS,
|
||||
}));
|
||||
|
||||
if (!moonshotHasModels) {
|
||||
moonshot.push({
|
||||
path: moonshotModelsPath as Array<string | number>,
|
||||
value: moonshotModelDefinitions,
|
||||
});
|
||||
}
|
||||
moonshot.push(setPrimary(`moonshot/${MOONSHOT_KIMI_K2_DEFAULT_ID}`));
|
||||
for (const model of MOONSHOT_KIMI_K2_MODELS) {
|
||||
const moonshotAlias = safeAlias(`moonshot/${model.id}`, model.alias);
|
||||
if (moonshotAlias) moonshot.push(moonshotAlias);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: "minimax",
|
||||
title: "MiniMax M2.1 (Anthropic)",
|
||||
description:
|
||||
"Adds provider config for MiniMax’s /anthropic endpoint and sets it as the default model.",
|
||||
patches: minimax,
|
||||
},
|
||||
{
|
||||
id: "zai",
|
||||
title: "GLM 4.7 (Z.AI)",
|
||||
description: "Adds ZAI_API_KEY placeholder + sets default model to zai/glm-4.7.",
|
||||
patches: zai,
|
||||
},
|
||||
{
|
||||
id: "moonshot",
|
||||
title: "Kimi (Moonshot)",
|
||||
description:
|
||||
"Adds Moonshot provider config + sets default model to kimi-k2-0905-preview (includes Kimi K2 turbo/thinking variants).",
|
||||
patches: moonshot,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function renderConfig(props: ConfigProps) {
|
||||
const validity =
|
||||
props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
|
||||
@@ -339,25 +45,6 @@ export function renderConfig(props: ConfigProps) {
|
||||
(props.formMode === "raw" ? true : canSaveForm);
|
||||
const canUpdate = props.connected && !props.applying && !props.updating;
|
||||
|
||||
const applyPreset = (patches: ConfigPatch[]) => {
|
||||
const base =
|
||||
props.formValue ??
|
||||
tryParseJsonObject(props.raw) ??
|
||||
({} as Record<string, unknown>);
|
||||
const next = cloneConfigObject(base);
|
||||
for (const patch of patches) {
|
||||
setPathValue(next, patch.path, patch.value);
|
||||
}
|
||||
props.onRawChange(`${JSON.stringify(next, null, 2).trimEnd()}\n`);
|
||||
for (const patch of patches) props.onFormPatch(patch.path, patch.value);
|
||||
};
|
||||
|
||||
const presetBase =
|
||||
props.formValue ??
|
||||
tryParseJsonObject(props.raw) ??
|
||||
({} as Record<string, unknown>);
|
||||
const modelPresets = buildModelPresetPatches(presetBase);
|
||||
|
||||
return html`
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
@@ -414,31 +101,6 @@ export function renderConfig(props: ConfigProps) {
|
||||
comes back.
|
||||
</div>
|
||||
|
||||
<div class="callout" style="margin-top: 12px;">
|
||||
<div style="font-weight: 600;">Model presets</div>
|
||||
<div class="muted" style="margin-top: 6px;">
|
||||
One-click inserts for MiniMax, GLM 4.7 (Z.AI), and Kimi (Moonshot). Keeps
|
||||
existing API keys and per-model params when present.
|
||||
</div>
|
||||
<div class="row" style="margin-top: 10px; flex-wrap: wrap;">
|
||||
${modelPresets.map(
|
||||
(preset) => html`
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${props.loading || props.saving || !props.connected}
|
||||
title=${preset.description}
|
||||
@click=${() => applyPreset(preset.patches)}
|
||||
>
|
||||
${preset.title}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<div class="muted" style="margin-top: 8px;">
|
||||
Tip: use <span class="mono">/model</span> to switch models without editing
|
||||
config.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${props.formMode === "form"
|
||||
? html`<div style="margin-top: 12px;">
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { DiscordActionForm, SlackActionForm } from "../ui-types";
|
||||
|
||||
export const discordActionOptions = [
|
||||
{ key: "reactions", label: "Reactions" },
|
||||
{ key: "stickers", label: "Stickers" },
|
||||
{ key: "polls", label: "Polls" },
|
||||
{ key: "permissions", label: "Permissions" },
|
||||
{ key: "messages", label: "Messages" },
|
||||
{ key: "threads", label: "Threads" },
|
||||
{ key: "pins", label: "Pins" },
|
||||
{ key: "search", label: "Search" },
|
||||
{ key: "memberInfo", label: "Member info" },
|
||||
{ key: "roleInfo", label: "Role info" },
|
||||
{ key: "channelInfo", label: "Channel info" },
|
||||
{ key: "voiceStatus", label: "Voice status" },
|
||||
{ key: "events", label: "Events" },
|
||||
{ key: "roles", label: "Role changes" },
|
||||
{ key: "moderation", label: "Moderation" },
|
||||
] satisfies Array<{ key: keyof DiscordActionForm; label: string }>;
|
||||
|
||||
export const slackActionOptions = [
|
||||
{ key: "reactions", label: "Reactions" },
|
||||
{ key: "messages", label: "Messages" },
|
||||
{ key: "pins", label: "Pins" },
|
||||
{ key: "memberInfo", label: "Member info" },
|
||||
{ key: "emojiList", label: "Emoji list" },
|
||||
] satisfies Array<{ key: keyof SlackActionForm; label: string }>;
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { html } from "lit";
|
||||
|
||||
import type { ConnectionsProps } from "./connections.types";
|
||||
import { discordActionOptions } from "./connections.action-options";
|
||||
|
||||
export function renderDiscordActionsSection(props: ConnectionsProps) {
|
||||
return html`
|
||||
<div class="card-sub" style="margin-top: 16px;">Tool actions</div>
|
||||
<div class="form-grid" style="margin-top: 8px;">
|
||||
${discordActionOptions.map(
|
||||
(action) => html`<label class="field">
|
||||
<span>${action.label}</span>
|
||||
<select
|
||||
.value=${props.discordForm.actions[action.key] ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
actions: {
|
||||
...props.discordForm.actions,
|
||||
[action.key]: (e.target as HTMLSelectElement).value === "yes",
|
||||
},
|
||||
})}
|
||||
>
|
||||
<option value="yes">Enabled</option>
|
||||
<option value="no">Disabled</option>
|
||||
</select>
|
||||
</label>`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import type { ConnectionsProps } from "./connections.types";
|
||||
|
||||
export function renderDiscordGuildsEditor(props: ConnectionsProps) {
|
||||
return html`
|
||||
<div class="field full">
|
||||
<span>Guilds</span>
|
||||
<div class="card-sub">
|
||||
Add each guild (id or slug) and optional channel rules. Empty channel
|
||||
entries still allow that channel.
|
||||
</div>
|
||||
<div class="list">
|
||||
${props.discordForm.guilds.map(
|
||||
(guild, guildIndex) => html`
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>Guild id / slug</span>
|
||||
<input
|
||||
.value=${guild.key}
|
||||
@input=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
key: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Slug</span>
|
||||
<input
|
||||
.value=${guild.slug}
|
||||
@input=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
slug: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Require mention</span>
|
||||
<select
|
||||
.value=${guild.requireMention ? "yes" : "no"}
|
||||
@change=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
requireMention:
|
||||
(e.target as HTMLSelectElement).value === "yes",
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Reaction notifications</span>
|
||||
<select
|
||||
.value=${guild.reactionNotifications}
|
||||
@change=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
reactionNotifications: (e.target as HTMLSelectElement)
|
||||
.value as "off" | "own" | "all" | "allowlist",
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="own">Own</option>
|
||||
<option value="all">All</option>
|
||||
<option value="allowlist">Allowlist</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Users allowlist</span>
|
||||
<input
|
||||
.value=${guild.users}
|
||||
@input=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
users: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
placeholder="123456789, username#1234"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
${guild.channels.length
|
||||
? html`
|
||||
<div class="form-grid" style="margin-top: 8px;">
|
||||
${guild.channels.map(
|
||||
(channel, channelIndex) => html`
|
||||
<label class="field">
|
||||
<span>Channel id / slug</span>
|
||||
<input
|
||||
.value=${channel.key}
|
||||
@input=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
const channels = [
|
||||
...(next[guildIndex].channels ?? []),
|
||||
];
|
||||
channels[channelIndex] = {
|
||||
...channels[channelIndex],
|
||||
key: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
channels,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Allow</span>
|
||||
<select
|
||||
.value=${channel.allow ? "yes" : "no"}
|
||||
@change=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
const channels = [
|
||||
...(next[guildIndex].channels ?? []),
|
||||
];
|
||||
channels[channelIndex] = {
|
||||
...channels[channelIndex],
|
||||
allow:
|
||||
(e.target as HTMLSelectElement).value ===
|
||||
"yes",
|
||||
};
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
channels,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Require mention</span>
|
||||
<select
|
||||
.value=${channel.requireMention ? "yes" : "no"}
|
||||
@change=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
const channels = [
|
||||
...(next[guildIndex].channels ?? []),
|
||||
];
|
||||
channels[channelIndex] = {
|
||||
...channels[channelIndex],
|
||||
requireMention:
|
||||
(e.target as HTMLSelectElement).value ===
|
||||
"yes",
|
||||
};
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
channels,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span> </span>
|
||||
<button
|
||||
class="btn"
|
||||
@click=${() => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
const channels = [
|
||||
...(next[guildIndex].channels ?? []),
|
||||
];
|
||||
channels.splice(channelIndex, 1);
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
channels,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</label>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
<span>Channels</span>
|
||||
<button
|
||||
class="btn"
|
||||
@click=${() => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
const channels = [
|
||||
...(next[guildIndex].channels ?? []),
|
||||
{ key: "", allow: true, requireMention: false },
|
||||
];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
channels,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
Add channel
|
||||
</button>
|
||||
<button
|
||||
class="btn danger"
|
||||
@click=${() => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next.splice(guildIndex, 1);
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
Remove guild
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
class="btn"
|
||||
style="margin-top: 8px;"
|
||||
@click=${() =>
|
||||
props.onDiscordChange({
|
||||
guilds: [
|
||||
...props.discordForm.guilds,
|
||||
{
|
||||
key: "",
|
||||
slug: "",
|
||||
requireMention: false,
|
||||
reactionNotifications: "own",
|
||||
users: "",
|
||||
channels: [],
|
||||
},
|
||||
],
|
||||
})}
|
||||
>
|
||||
Add guild
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { DiscordStatus } from "../types";
|
||||
import type { ConnectionsProps } from "./connections.types";
|
||||
import { renderDiscordActionsSection } from "./connections.discord.actions";
|
||||
import { renderDiscordGuildsEditor } from "./connections.discord.guilds";
|
||||
|
||||
export function renderDiscordCard(params: {
|
||||
props: ConnectionsProps;
|
||||
discord: DiscordStatus | null;
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, discord, accountCountLabel } = params;
|
||||
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>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${discord?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${discord?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<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;">
|
||||
${discord.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${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>DMs enabled</span>
|
||||
<select
|
||||
.value=${props.discordForm.dmEnabled ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
dmEnabled: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Enabled</option>
|
||||
<option value="no">Disabled</option>
|
||||
</select>
|
||||
</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>Text chunk limit</span>
|
||||
<input
|
||||
.value=${props.discordForm.textChunkLimit}
|
||||
@input=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
textChunkLimit: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="2000"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Reply to mode</span>
|
||||
<select
|
||||
.value=${props.discordForm.replyToMode}
|
||||
@change=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
replyToMode: (e.target as HTMLSelectElement).value as
|
||||
| "off"
|
||||
| "first"
|
||||
| "all",
|
||||
})}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="first">First</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</label>
|
||||
${renderDiscordGuildsEditor(props)}
|
||||
<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>
|
||||
|
||||
${renderDiscordActionsSection(props)}
|
||||
|
||||
${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>
|
||||
`;
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { IMessageStatus } from "../types";
|
||||
import type { ConnectionsProps } from "./connections.types";
|
||||
|
||||
export function renderIMessageCard(params: {
|
||||
props: ConnectionsProps;
|
||||
imessage: IMessageStatus | null;
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, imessage, accountCountLabel } = params;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">iMessage</div>
|
||||
<div class="card-sub">imsg CLI and database availability.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import type {
|
||||
DiscordStatus,
|
||||
IMessageStatus,
|
||||
SignalStatus,
|
||||
SlackStatus,
|
||||
TelegramStatus,
|
||||
WhatsAppStatus,
|
||||
} from "../types";
|
||||
import type { ChannelAccountSnapshot } from "../types";
|
||||
import type { ChannelKey, ConnectionsProps } from "./connections.types";
|
||||
|
||||
export 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`;
|
||||
}
|
||||
|
||||
export function channelEnabled(key: ChannelKey, props: ConnectionsProps) {
|
||||
const snapshot = props.snapshot;
|
||||
const channels = snapshot?.channels as Record<string, unknown> | null;
|
||||
if (!snapshot || !channels) return false;
|
||||
const whatsapp = channels.whatsapp as WhatsAppStatus | undefined;
|
||||
const telegram = channels.telegram as TelegramStatus | undefined;
|
||||
const discord = (channels.discord ?? null) as DiscordStatus | null;
|
||||
const slack = (channels.slack ?? null) as SlackStatus | null;
|
||||
const signal = (channels.signal ?? null) as SignalStatus | null;
|
||||
const imessage = (channels.imessage ?? null) as IMessageStatus | null;
|
||||
switch (key) {
|
||||
case "whatsapp":
|
||||
return (
|
||||
Boolean(whatsapp?.configured) ||
|
||||
Boolean(whatsapp?.linked) ||
|
||||
Boolean(whatsapp?.running)
|
||||
);
|
||||
case "telegram":
|
||||
return Boolean(telegram?.configured) || Boolean(telegram?.running);
|
||||
case "discord":
|
||||
return Boolean(discord?.configured || discord?.running);
|
||||
case "slack":
|
||||
return Boolean(slack?.configured || slack?.running);
|
||||
case "signal":
|
||||
return Boolean(signal?.configured || signal?.running);
|
||||
case "imessage":
|
||||
return Boolean(imessage?.configured || imessage?.running);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getChannelAccountCount(
|
||||
key: ChannelKey,
|
||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
|
||||
): number {
|
||||
return channelAccounts?.[key]?.length ?? 0;
|
||||
}
|
||||
|
||||
export function renderChannelAccountCount(
|
||||
key: ChannelKey,
|
||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
|
||||
) {
|
||||
const count = getChannelAccountCount(key, channelAccounts);
|
||||
if (count < 2) return nothing;
|
||||
return html`<div class="account-count">Accounts (${count})</div>`;
|
||||
}
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { SignalStatus } from "../types";
|
||||
import type { ConnectionsProps } from "./connections.types";
|
||||
|
||||
export function renderSignalCard(params: {
|
||||
props: ConnectionsProps;
|
||||
signal: SignalStatus | null;
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, signal, accountCountLabel } = params;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Signal</div>
|
||||
<div class="card-sub">REST daemon status and probe details.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { SlackStatus } from "../types";
|
||||
import type { ConnectionsProps } from "./connections.types";
|
||||
import { slackActionOptions } from "./connections.action-options";
|
||||
|
||||
export function renderSlackCard(params: {
|
||||
props: ConnectionsProps;
|
||||
slack: SlackStatus | null;
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, slack, accountCountLabel } = params;
|
||||
const botName = slack?.probe?.bot?.name;
|
||||
const teamName = slack?.probe?.team?.name;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Slack</div>
|
||||
<div class="card-sub">Socket mode status and bot details.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${slack?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${slack?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Bot</span>
|
||||
<span>${botName ? botName : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Team</span>
|
||||
<span>${teamName ? teamName : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${slack?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${slack.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${slack?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${slack.probe.ok ? "ok" : "failed"} · ${slack.probe.status ?? ""}
|
||||
${slack.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="form-grid" style="margin-top: 16px;">
|
||||
<label class="field">
|
||||
<span>Enabled</span>
|
||||
<select
|
||||
.value=${props.slackForm.enabled ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
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.slackForm.botToken}
|
||||
?disabled=${props.slackTokenLocked}
|
||||
@input=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
botToken: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>App token</span>
|
||||
<input
|
||||
type="password"
|
||||
.value=${props.slackForm.appToken}
|
||||
?disabled=${props.slackAppTokenLocked}
|
||||
@input=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
appToken: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>DMs enabled</span>
|
||||
<select
|
||||
.value=${props.slackForm.dmEnabled ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
dmEnabled: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Enabled</option>
|
||||
<option value="no">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Allow DMs from</span>
|
||||
<input
|
||||
.value=${props.slackForm.allowFrom}
|
||||
@input=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
allowFrom: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="U123, U456, *"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Group DMs enabled</span>
|
||||
<select
|
||||
.value=${props.slackForm.groupEnabled ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
groupEnabled: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Enabled</option>
|
||||
<option value="no">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Group DM channels</span>
|
||||
<input
|
||||
.value=${props.slackForm.groupChannels}
|
||||
@input=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
groupChannels: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="G123, #team"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Reaction notifications</span>
|
||||
<select
|
||||
.value=${props.slackForm.reactionNotifications}
|
||||
@change=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
reactionNotifications: (e.target as HTMLSelectElement)
|
||||
.value as "off" | "own" | "all" | "allowlist",
|
||||
})}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="own">Own</option>
|
||||
<option value="all">All</option>
|
||||
<option value="allowlist">Allowlist</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Reaction allowlist</span>
|
||||
<input
|
||||
.value=${props.slackForm.reactionAllowlist}
|
||||
@input=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
reactionAllowlist: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="U123, U456"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Text chunk limit</span>
|
||||
<input
|
||||
.value=${props.slackForm.textChunkLimit}
|
||||
@input=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
textChunkLimit: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="4000"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Media max (MB)</span>
|
||||
<input
|
||||
.value=${props.slackForm.mediaMaxMb}
|
||||
@input=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
mediaMaxMb: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="20"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card-sub" style="margin-top: 16px;">Slash command</div>
|
||||
<div class="form-grid" style="margin-top: 8px;">
|
||||
<label class="field">
|
||||
<span>Slash enabled</span>
|
||||
<select
|
||||
.value=${props.slackForm.slashEnabled ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
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.slackForm.slashName}
|
||||
@input=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
slashName: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="clawd"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Slash session prefix</span>
|
||||
<input
|
||||
.value=${props.slackForm.slashSessionPrefix}
|
||||
@input=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
slashSessionPrefix: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="slack:slash"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Slash ephemeral</span>
|
||||
<select
|
||||
.value=${props.slackForm.slashEphemeral ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
slashEphemeral: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card-sub" style="margin-top: 16px;">Channels</div>
|
||||
<div class="card-sub">Add channel ids or #names and optionally require mentions.</div>
|
||||
<div class="list">
|
||||
${props.slackForm.channels.map(
|
||||
(channel, channelIndex) => html`
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>Channel id / name</span>
|
||||
<input
|
||||
.value=${channel.key}
|
||||
@input=${(e: Event) => {
|
||||
const next = [...props.slackForm.channels];
|
||||
next[channelIndex] = {
|
||||
...next[channelIndex],
|
||||
key: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
props.onSlackChange({ channels: next });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Allow</span>
|
||||
<select
|
||||
.value=${channel.allow ? "yes" : "no"}
|
||||
@change=${(e: Event) => {
|
||||
const next = [...props.slackForm.channels];
|
||||
next[channelIndex] = {
|
||||
...next[channelIndex],
|
||||
allow: (e.target as HTMLSelectElement).value === "yes",
|
||||
};
|
||||
props.onSlackChange({ channels: next });
|
||||
}}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Require mention</span>
|
||||
<select
|
||||
.value=${channel.requireMention ? "yes" : "no"}
|
||||
@change=${(e: Event) => {
|
||||
const next = [...props.slackForm.channels];
|
||||
next[channelIndex] = {
|
||||
...next[channelIndex],
|
||||
requireMention:
|
||||
(e.target as HTMLSelectElement).value === "yes",
|
||||
};
|
||||
props.onSlackChange({ channels: next });
|
||||
}}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span> </span>
|
||||
<button
|
||||
class="btn"
|
||||
@click=${() => {
|
||||
const next = [...props.slackForm.channels];
|
||||
next.splice(channelIndex, 1);
|
||||
props.onSlackChange({ channels: next });
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
class="btn"
|
||||
style="margin-top: 8px;"
|
||||
@click=${() =>
|
||||
props.onSlackChange({
|
||||
channels: [
|
||||
...props.slackForm.channels,
|
||||
{ key: "", allow: true, requireMention: false },
|
||||
],
|
||||
})}
|
||||
>
|
||||
Add channel
|
||||
</button>
|
||||
|
||||
<div class="card-sub" style="margin-top: 16px;">Tool actions</div>
|
||||
<div class="form-grid" style="margin-top: 8px;">
|
||||
${slackActionOptions.map(
|
||||
(action) => html`<label class="field">
|
||||
<span>${action.label}</span>
|
||||
<select
|
||||
.value=${props.slackForm.actions[action.key] ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onSlackChange({
|
||||
actions: {
|
||||
...props.slackForm.actions,
|
||||
[action.key]: (e.target as HTMLSelectElement).value === "yes",
|
||||
},
|
||||
})}
|
||||
>
|
||||
<option value="yes">Enabled</option>
|
||||
<option value="no">Disabled</option>
|
||||
</select>
|
||||
</label>`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
${props.slackTokenLocked || props.slackAppTokenLocked
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
${props.slackTokenLocked ? "SLACK_BOT_TOKEN " : ""}
|
||||
${props.slackAppTokenLocked ? "SLACK_APP_TOKEN " : ""} is set in the
|
||||
environment. Config edits will not override it.
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${props.slackStatus
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
${props.slackStatus}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="row" style="margin-top: 14px;">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${props.slackSaving}
|
||||
@click=${() => props.onSlackSave()}
|
||||
>
|
||||
${props.slackSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { ChannelAccountSnapshot, TelegramStatus } from "../types";
|
||||
import type { ConnectionsProps } from "./connections.types";
|
||||
|
||||
export function renderTelegramCard(params: {
|
||||
props: ConnectionsProps;
|
||||
telegram?: TelegramStatus;
|
||||
telegramAccounts: ChannelAccountSnapshot[];
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, telegram, telegramAccounts, accountCountLabel } = params;
|
||||
const hasMultipleAccounts = telegramAccounts.length > 1;
|
||||
|
||||
const renderAccountCard = (account: ChannelAccountSnapshot) => {
|
||||
const probe = account.probe as { bot?: { username?: string } } | undefined;
|
||||
const botUsername = probe?.bot?.username;
|
||||
const label = account.name || account.accountId;
|
||||
return html`
|
||||
<div class="account-card">
|
||||
<div class="account-card-header">
|
||||
<div class="account-card-title">
|
||||
${botUsername ? `@${botUsername}` : label}
|
||||
</div>
|
||||
<div class="account-card-id">${account.accountId}</div>
|
||||
</div>
|
||||
<div class="status-list account-card-status">
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${account.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${account.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last inbound</span>
|
||||
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
|
||||
</div>
|
||||
${account.lastError
|
||||
? html`
|
||||
<div class="account-card-error">
|
||||
${account.lastError}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Telegram</div>
|
||||
<div class="card-sub">Bot token and delivery options.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
${hasMultipleAccounts
|
||||
? html`
|
||||
<div class="account-card-list">
|
||||
${telegramAccounts.map((account) => renderAccountCard(account))}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${telegram?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${telegram?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Mode</span>
|
||||
<span>${telegram?.mode ?? "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${telegram?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${telegram.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${telegram?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
|
||||
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="form-grid" style="margin-top: 16px;">
|
||||
<label class="field">
|
||||
<span>Bot token</span>
|
||||
<input
|
||||
type="password"
|
||||
.value=${props.telegramForm.token}
|
||||
?disabled=${props.telegramTokenLocked}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
token: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Apply default group rules</span>
|
||||
<select
|
||||
.value=${props.telegramForm.groupsWildcardEnabled ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
groupsWildcardEnabled:
|
||||
(e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="no">No</option>
|
||||
<option value="yes">Yes (allow all groups)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Require mention in groups</span>
|
||||
<select
|
||||
.value=${props.telegramForm.requireMention ? "yes" : "no"}
|
||||
?disabled=${!props.telegramForm.groupsWildcardEnabled}
|
||||
@change=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
requireMention: (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.telegramForm.allowFrom}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
allowFrom: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="123456789, @team, tg:123"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Proxy</span>
|
||||
<input
|
||||
.value=${props.telegramForm.proxy}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
proxy: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="socks5://localhost:9050"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Webhook URL</span>
|
||||
<input
|
||||
.value=${props.telegramForm.webhookUrl}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
webhookUrl: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="https://example.com/telegram-webhook"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Webhook secret</span>
|
||||
<input
|
||||
.value=${props.telegramForm.webhookSecret}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
webhookSecret: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="secret"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Webhook path</span>
|
||||
<input
|
||||
.value=${props.telegramForm.webhookPath}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
webhookPath: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="/telegram-webhook"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="callout" style="margin-top: 12px;">
|
||||
Allow from supports numeric user IDs (recommended) or @usernames. DM the bot
|
||||
to get your ID, or run /whoami.
|
||||
</div>
|
||||
|
||||
${props.telegramTokenLocked
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
TELEGRAM_BOT_TOKEN is set in the environment. Config edits will not override it.
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${props.telegramForm.groupsWildcardEnabled
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
This writes telegram.groups["*"] and allows all groups. Remove it
|
||||
if you only want specific groups.
|
||||
<div class="row" style="margin-top: 8px;">
|
||||
<button
|
||||
class="btn"
|
||||
@click=${() => props.onTelegramChange({ groupsWildcardEnabled: false })}
|
||||
>
|
||||
Remove wildcard
|
||||
</button>
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${props.telegramStatus
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
${props.telegramStatus}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="row" style="margin-top: 14px;">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${props.telegramSaving}
|
||||
@click=${() => props.onTelegramSave()}
|
||||
>
|
||||
${props.telegramSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type {
|
||||
DiscordStatus,
|
||||
IMessageStatus,
|
||||
SignalStatus,
|
||||
SlackStatus,
|
||||
TelegramStatus,
|
||||
WhatsAppStatus,
|
||||
} from "../types";
|
||||
import type {
|
||||
ChannelKey,
|
||||
ConnectionsChannelData,
|
||||
ConnectionsProps,
|
||||
} from "./connections.types";
|
||||
import { channelEnabled, renderChannelAccountCount } from "./connections.shared";
|
||||
import { renderDiscordCard } from "./connections.discord";
|
||||
import { renderIMessageCard } from "./connections.imessage";
|
||||
import { renderSignalCard } from "./connections.signal";
|
||||
import { renderSlackCard } from "./connections.slack";
|
||||
import { renderTelegramCard } from "./connections.telegram";
|
||||
import { renderWhatsAppCard } from "./connections.whatsapp";
|
||||
|
||||
export function renderConnections(props: ConnectionsProps) {
|
||||
const channels = props.snapshot?.channels as Record<string, unknown> | null;
|
||||
const whatsapp = (channels?.whatsapp ?? undefined) as
|
||||
| WhatsAppStatus
|
||||
| undefined;
|
||||
const telegram = (channels?.telegram ?? undefined) as
|
||||
| TelegramStatus
|
||||
| undefined;
|
||||
const discord = (channels?.discord ?? null) as DiscordStatus | null;
|
||||
const slack = (channels?.slack ?? null) as SlackStatus | null;
|
||||
const signal = (channels?.signal ?? null) as SignalStatus | null;
|
||||
const imessage = (channels?.imessage ?? null) as IMessageStatus | null;
|
||||
const channelOrder: ChannelKey[] = [
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
"discord",
|
||||
"slack",
|
||||
"signal",
|
||||
"imessage",
|
||||
];
|
||||
const orderedChannels = channelOrder
|
||||
.map((key, index) => ({
|
||||
key,
|
||||
enabled: channelEnabled(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">
|
||||
${orderedChannels.map((channel) =>
|
||||
renderChannel(channel.key, props, {
|
||||
whatsapp,
|
||||
telegram,
|
||||
discord,
|
||||
slack,
|
||||
signal,
|
||||
imessage,
|
||||
channelAccounts: props.snapshot?.channelAccounts ?? null,
|
||||
}),
|
||||
)}
|
||||
</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">Channel 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 renderChannel(
|
||||
key: ChannelKey,
|
||||
props: ConnectionsProps,
|
||||
data: ConnectionsChannelData,
|
||||
) {
|
||||
const accountCountLabel = renderChannelAccountCount(
|
||||
key,
|
||||
data.channelAccounts,
|
||||
);
|
||||
switch (key) {
|
||||
case "whatsapp":
|
||||
return renderWhatsAppCard({
|
||||
props,
|
||||
whatsapp: data.whatsapp,
|
||||
accountCountLabel,
|
||||
});
|
||||
case "telegram":
|
||||
return renderTelegramCard({
|
||||
props,
|
||||
telegram: data.telegram,
|
||||
telegramAccounts: data.channelAccounts?.telegram ?? [],
|
||||
accountCountLabel,
|
||||
});
|
||||
case "discord":
|
||||
return renderDiscordCard({
|
||||
props,
|
||||
discord: data.discord,
|
||||
accountCountLabel,
|
||||
});
|
||||
case "slack":
|
||||
return renderSlackCard({
|
||||
props,
|
||||
slack: data.slack,
|
||||
accountCountLabel,
|
||||
});
|
||||
case "signal":
|
||||
return renderSignalCard({
|
||||
props,
|
||||
signal: data.signal,
|
||||
accountCountLabel,
|
||||
});
|
||||
case "imessage":
|
||||
return renderIMessageCard({
|
||||
props,
|
||||
imessage: data.imessage,
|
||||
accountCountLabel,
|
||||
});
|
||||
default:
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelsStatusSnapshot,
|
||||
DiscordStatus,
|
||||
IMessageStatus,
|
||||
SignalStatus,
|
||||
SlackStatus,
|
||||
TelegramStatus,
|
||||
WhatsAppStatus,
|
||||
} from "../types";
|
||||
import type {
|
||||
DiscordForm,
|
||||
IMessageForm,
|
||||
SignalForm,
|
||||
SlackForm,
|
||||
TelegramForm,
|
||||
} from "../ui-types";
|
||||
|
||||
export type ChannelKey =
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
|
||||
export type ConnectionsProps = {
|
||||
connected: boolean;
|
||||
loading: boolean;
|
||||
snapshot: ChannelsStatusSnapshot | null;
|
||||
lastError: string | null;
|
||||
lastSuccessAt: number | null;
|
||||
whatsappMessage: string | null;
|
||||
whatsappQrDataUrl: string | null;
|
||||
whatsappConnected: boolean | null;
|
||||
whatsappBusy: boolean;
|
||||
telegramForm: TelegramForm;
|
||||
telegramTokenLocked: boolean;
|
||||
telegramSaving: boolean;
|
||||
telegramStatus: string | null;
|
||||
discordForm: DiscordForm;
|
||||
discordTokenLocked: boolean;
|
||||
discordSaving: boolean;
|
||||
discordStatus: string | null;
|
||||
slackForm: SlackForm;
|
||||
slackTokenLocked: boolean;
|
||||
slackAppTokenLocked: boolean;
|
||||
slackSaving: boolean;
|
||||
slackStatus: 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;
|
||||
onSlackChange: (patch: Partial<SlackForm>) => void;
|
||||
onSlackSave: () => void;
|
||||
onSignalChange: (patch: Partial<SignalForm>) => void;
|
||||
onSignalSave: () => void;
|
||||
onIMessageChange: (patch: Partial<IMessageForm>) => void;
|
||||
onIMessageSave: () => void;
|
||||
};
|
||||
|
||||
export type ConnectionsChannelData = {
|
||||
whatsapp?: WhatsAppStatus;
|
||||
telegram?: TelegramStatus;
|
||||
discord?: DiscordStatus | null;
|
||||
slack?: SlackStatus | null;
|
||||
signal?: SignalStatus | null;
|
||||
imessage?: IMessageStatus | null;
|
||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export function renderNodes(props: NodesProps) {
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
<div class="card-title">Nodes</div>
|
||||
<div class="card-sub">Paired devices and live connections.</div>
|
||||
<div class="card-sub">Paired devices and live links.</div>
|
||||
</div>
|
||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
|
||||
@@ -169,7 +169,7 @@ export function renderOverview(props: OverviewProps) {
|
||||
${authHint ?? ""}
|
||||
</div>`
|
||||
: html`<div class="callout" style="margin-top: 14px;">
|
||||
Use Connections to link WhatsApp, Telegram, Discord, Signal, or iMessage.
|
||||
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
|
||||
</div>`}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user