fix: finish channels rename sweep

This commit is contained in:
Peter Steinberger
2026-01-13 08:11:59 +00:00
parent fcac2464e6
commit 84bfaad6e6
52 changed files with 579 additions and 578 deletions

View File

@@ -187,7 +187,7 @@ actor BridgeServer {
thinking: "low", thinking: "low",
deliver: false, deliver: false,
to: nil, to: nil,
provider: .last)) channel: .last))
case "agent.request": case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
@@ -205,7 +205,7 @@ actor BridgeServer {
?? "node-\(nodeId)" ?? "node-\(nodeId)"
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let provider = GatewayAgentProvider(raw: link.channel) let channel = GatewayAgentChannel(raw: link.channel)
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( _ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: message, message: message,
@@ -213,7 +213,7 @@ actor BridgeServer {
thinking: thinking, thinking: thinking,
deliver: link.deliver, deliver: link.deliver,
to: to, to: to,
provider: provider)) channel: channel))
default: default:
break break

View File

@@ -11,9 +11,9 @@ extension ConnectionsSettings {
} }
@ViewBuilder @ViewBuilder
func providerHeaderActions(_ provider: ConnectionProvider) -> some View { func channelHeaderActions(_ channel: ConnectionChannel) -> some View {
HStack(spacing: 8) { HStack(spacing: 8) {
if provider == .whatsapp { if channel == .whatsapp {
Button("Logout") { Button("Logout") {
Task { await self.store.logoutWhatsApp() } Task { await self.store.logoutWhatsApp() }
} }
@@ -21,7 +21,7 @@ extension ConnectionsSettings {
.disabled(self.store.whatsappBusy) .disabled(self.store.whatsappBusy)
} }
if provider == .telegram { if channel == .telegram {
Button("Logout") { Button("Logout") {
Task { await self.store.logoutTelegram() } Task { await self.store.logoutTelegram() }
} }

View File

@@ -1,15 +1,15 @@
import SwiftUI import SwiftUI
extension ConnectionsSettings { extension ConnectionsSettings {
private func providerStatus<T: Decodable>( private func channelStatus<T: Decodable>(
_ id: String, _ id: String,
as type: T.Type) -> T? as type: T.Type) -> T?
{ {
self.store.snapshot?.decodeProvider(id, as: type) self.store.snapshot?.decodeChannel(id, as: type)
} }
var whatsAppTint: Color { var whatsAppTint: Color {
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self) guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return .secondary } else { return .secondary }
if !status.configured { return .secondary } if !status.configured { return .secondary }
if !status.linked { return .red } if !status.linked { return .red }
@@ -20,7 +20,7 @@ extension ConnectionsSettings {
} }
var telegramTint: Color { var telegramTint: Color {
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self) guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return .secondary } else { return .secondary }
if !status.configured { return .secondary } if !status.configured { return .secondary }
if status.lastError != nil { return .orange } if status.lastError != nil { return .orange }
@@ -30,7 +30,7 @@ extension ConnectionsSettings {
} }
var discordTint: Color { var discordTint: Color {
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self) guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return .secondary } else { return .secondary }
if !status.configured { return .secondary } if !status.configured { return .secondary }
if status.lastError != nil { return .orange } if status.lastError != nil { return .orange }
@@ -40,7 +40,7 @@ extension ConnectionsSettings {
} }
var signalTint: Color { var signalTint: Color {
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self) guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return .secondary } else { return .secondary }
if !status.configured { return .secondary } if !status.configured { return .secondary }
if status.lastError != nil { return .orange } if status.lastError != nil { return .orange }
@@ -50,7 +50,7 @@ extension ConnectionsSettings {
} }
var imessageTint: Color { var imessageTint: Color {
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self) guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return .secondary } else { return .secondary }
if !status.configured { return .secondary } if !status.configured { return .secondary }
if status.lastError != nil { return .orange } if status.lastError != nil { return .orange }
@@ -60,7 +60,7 @@ extension ConnectionsSettings {
} }
var whatsAppSummary: String { var whatsAppSummary: String {
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self) guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return "Checking…" } else { return "Checking…" }
if !status.linked { return "Not linked" } if !status.linked { return "Not linked" }
if status.connected { return "Connected" } if status.connected { return "Connected" }
@@ -69,7 +69,7 @@ extension ConnectionsSettings {
} }
var telegramSummary: String { var telegramSummary: String {
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self) guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return "Checking…" } else { return "Checking…" }
if !status.configured { return "Not configured" } if !status.configured { return "Not configured" }
if status.running { return "Running" } if status.running { return "Running" }
@@ -77,7 +77,7 @@ extension ConnectionsSettings {
} }
var discordSummary: String { var discordSummary: String {
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self) guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return "Checking…" } else { return "Checking…" }
if !status.configured { return "Not configured" } if !status.configured { return "Not configured" }
if status.running { return "Running" } if status.running { return "Running" }
@@ -85,7 +85,7 @@ extension ConnectionsSettings {
} }
var signalSummary: String { var signalSummary: String {
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self) guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return "Checking…" } else { return "Checking…" }
if !status.configured { return "Not configured" } if !status.configured { return "Not configured" }
if status.running { return "Running" } if status.running { return "Running" }
@@ -93,7 +93,7 @@ extension ConnectionsSettings {
} }
var imessageSummary: String { var imessageSummary: String {
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self) guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return "Checking…" } else { return "Checking…" }
if !status.configured { return "Not configured" } if !status.configured { return "Not configured" }
if status.running { return "Running" } if status.running { return "Running" }
@@ -101,7 +101,7 @@ extension ConnectionsSettings {
} }
var whatsAppDetails: String? { var whatsAppDetails: String? {
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self) guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return nil } else { return nil }
var lines: [String] = [] var lines: [String] = []
if let e164 = status.`self`?.e164 ?? status.`self`?.jid { if let e164 = status.`self`?.e164 ?? status.`self`?.jid {
@@ -132,7 +132,7 @@ extension ConnectionsSettings {
} }
var telegramDetails: String? { var telegramDetails: String? {
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self) guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return nil } else { return nil }
var lines: [String] = [] var lines: [String] = []
if let source = status.tokenSource { if let source = status.tokenSource {
@@ -164,7 +164,7 @@ extension ConnectionsSettings {
} }
var discordDetails: String? { var discordDetails: String? {
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self) guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return nil } else { return nil }
var lines: [String] = [] var lines: [String] = []
if let source = status.tokenSource { if let source = status.tokenSource {
@@ -193,7 +193,7 @@ extension ConnectionsSettings {
} }
var signalDetails: String? { var signalDetails: String? {
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self) guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return nil } else { return nil }
var lines: [String] = [] var lines: [String] = []
lines.append("Base URL: \(status.baseUrl)") lines.append("Base URL: \(status.baseUrl)")
@@ -220,7 +220,7 @@ extension ConnectionsSettings {
} }
var imessageDetails: String? { var imessageDetails: String? {
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self) guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return nil } else { return nil }
var lines: [String] = [] var lines: [String] = []
if let cliPath = status.cliPath, !cliPath.isEmpty { if let cliPath = status.cliPath, !cliPath.isEmpty {
@@ -243,68 +243,68 @@ extension ConnectionsSettings {
} }
var isTelegramTokenLocked: Bool { var isTelegramTokenLocked: Bool {
self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)?.tokenSource == "env" self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
} }
var isDiscordTokenLocked: Bool { var isDiscordTokenLocked: Bool {
self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)?.tokenSource == "env" self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
} }
var orderedProviders: [ConnectionProvider] { var orderedChannels: [ConnectionChannel] {
ConnectionProvider.allCases.sorted { lhs, rhs in ConnectionChannel.allCases.sorted { lhs, rhs in
let lhsEnabled = self.providerEnabled(lhs) let lhsEnabled = self.channelEnabled(lhs)
let rhsEnabled = self.providerEnabled(rhs) let rhsEnabled = self.channelEnabled(rhs)
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled } if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
return lhs.sortOrder < rhs.sortOrder return lhs.sortOrder < rhs.sortOrder
} }
} }
var enabledProviders: [ConnectionProvider] { var enabledChannels: [ConnectionChannel] {
self.orderedProviders.filter { self.providerEnabled($0) } self.orderedChannels.filter { self.channelEnabled($0) }
} }
var availableProviders: [ConnectionProvider] { var availableChannels: [ConnectionChannel] {
self.orderedProviders.filter { !self.providerEnabled($0) } self.orderedChannels.filter { !self.channelEnabled($0) }
} }
func ensureSelection() { func ensureSelection() {
guard let selected = self.selectedProvider else { guard let selected = self.selectedChannel else {
self.selectedProvider = self.orderedProviders.first self.selectedChannel = self.orderedChannels.first
return return
} }
if !self.orderedProviders.contains(selected) { if !self.orderedChannels.contains(selected) {
self.selectedProvider = self.orderedProviders.first self.selectedChannel = self.orderedChannels.first
} }
} }
func providerEnabled(_ provider: ConnectionProvider) -> Bool { func channelEnabled(_ channel: ConnectionChannel) -> Bool {
switch provider { switch channel {
case .whatsapp: case .whatsapp:
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self) guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return false } else { return false }
return status.configured || status.linked || status.running return status.configured || status.linked || status.running
case .telegram: case .telegram:
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self) guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return false } else { return false }
return status.configured || status.running return status.configured || status.running
case .discord: case .discord:
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self) guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false } else { return false }
return status.configured || status.running return status.configured || status.running
case .signal: case .signal:
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self) guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false } else { return false }
return status.configured || status.running return status.configured || status.running
case .imessage: case .imessage:
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self) guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return false } else { return false }
return status.configured || status.running return status.configured || status.running
} }
} }
@ViewBuilder @ViewBuilder
func providerSection(_ provider: ConnectionProvider) -> some View { func channelSection(_ channel: ConnectionChannel) -> some View {
switch provider { switch channel {
case .whatsapp: case .whatsapp:
self.whatsAppSection self.whatsAppSection
case .telegram: case .telegram:
@@ -318,8 +318,8 @@ extension ConnectionsSettings {
} }
} }
func providerTint(_ provider: ConnectionProvider) -> Color { func channelTint(_ channel: ConnectionChannel) -> Color {
switch provider { switch channel {
case .whatsapp: case .whatsapp:
self.whatsAppTint self.whatsAppTint
case .telegram: case .telegram:
@@ -333,8 +333,8 @@ extension ConnectionsSettings {
} }
} }
func providerSummary(_ provider: ConnectionProvider) -> String { func channelSummary(_ channel: ConnectionChannel) -> String {
switch provider { switch channel {
case .whatsapp: case .whatsapp:
self.whatsAppSummary self.whatsAppSummary
case .telegram: case .telegram:
@@ -348,8 +348,8 @@ extension ConnectionsSettings {
} }
} }
func providerDetails(_ provider: ConnectionProvider) -> String? { func channelDetails(_ channel: ConnectionChannel) -> String? {
switch provider { switch channel {
case .whatsapp: case .whatsapp:
self.whatsAppDetails self.whatsAppDetails
case .telegram: case .telegram:
@@ -363,55 +363,55 @@ extension ConnectionsSettings {
} }
} }
func providerLastCheckText(_ provider: ConnectionProvider) -> String { func channelLastCheckText(_ channel: ConnectionChannel) -> String {
guard let date = self.providerLastCheck(provider) else { return "never" } guard let date = self.channelLastCheck(channel) else { return "never" }
return relativeAge(from: date) return relativeAge(from: date)
} }
func providerLastCheck(_ provider: ConnectionProvider) -> Date? { func channelLastCheck(_ channel: ConnectionChannel) -> Date? {
switch provider { switch channel {
case .whatsapp: case .whatsapp:
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self) guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return nil } else { return nil }
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt) return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
case .telegram: case .telegram:
return self return self
.date(fromMs: self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)? .date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.lastProbeAt) .lastProbeAt)
case .discord: case .discord:
return self return self
.date(fromMs: self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)? .date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.lastProbeAt) .lastProbeAt)
case .signal: case .signal:
return self return self
.date(fromMs: self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)?.lastProbeAt) .date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
case .imessage: case .imessage:
return self return self
.date(fromMs: self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)? .date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
.lastProbeAt) .lastProbeAt)
} }
} }
func providerHasError(_ provider: ConnectionProvider) -> Bool { func channelHasError(_ channel: ConnectionChannel) -> Bool {
switch provider { switch channel {
case .whatsapp: case .whatsapp:
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self) guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return false } else { return false }
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
case .telegram: case .telegram:
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self) guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return false } else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
case .discord: case .discord:
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self) guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false } else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
case .signal: case .signal:
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self) guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false } else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
case .imessage: case .imessage:
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self) guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return false } else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
} }

View File

@@ -11,7 +11,7 @@ extension ConnectionsSettings {
self.store.start() self.store.start()
self.ensureSelection() self.ensureSelection()
} }
.onChange(of: self.orderedProviders) { _, _ in .onChange(of: self.orderedChannels) { _, _ in
self.ensureSelection() self.ensureSelection()
} }
.onDisappear { self.store.stop() } .onDisappear { self.store.stop() }
@@ -20,17 +20,17 @@ extension ConnectionsSettings {
private var sidebar: some View { private var sidebar: some View {
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 8) { LazyVStack(alignment: .leading, spacing: 8) {
if !self.enabledProviders.isEmpty { if !self.enabledChannels.isEmpty {
self.sidebarSectionHeader("Configured") self.sidebarSectionHeader("Configured")
ForEach(self.enabledProviders) { provider in ForEach(self.enabledChannels) { channel in
self.sidebarRow(provider) self.sidebarRow(channel)
} }
} }
if !self.availableProviders.isEmpty { if !self.availableChannels.isEmpty {
self.sidebarSectionHeader("Available") self.sidebarSectionHeader("Available")
ForEach(self.availableProviders) { provider in ForEach(self.availableChannels) { channel in
self.sidebarRow(provider) self.sidebarRow(channel)
} }
} }
} }
@@ -46,8 +46,8 @@ extension ConnectionsSettings {
private var detail: some View { private var detail: some View {
Group { Group {
if let provider = self.selectedProvider { if let channel = self.selectedChannel {
self.providerDetail(provider) self.channelDetail(channel)
} else { } else {
self.emptyDetail self.emptyDetail
} }
@@ -59,7 +59,7 @@ extension ConnectionsSettings {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Connections") Text("Connections")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
Text("Select a provider to view status and settings.") Text("Select a channel to view status and settings.")
.font(.callout) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -67,12 +67,12 @@ extension ConnectionsSettings {
.padding(.vertical, 18) .padding(.vertical, 18)
} }
private func providerDetail(_ provider: ConnectionProvider) -> some View { private func channelDetail(_ channel: ConnectionChannel) -> some View {
ScrollView(.vertical) { ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
self.detailHeader(for: provider) self.detailHeader(for: channel)
Divider() Divider()
self.providerSection(provider) self.channelSection(channel)
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -81,18 +81,18 @@ extension ConnectionsSettings {
} }
} }
private func sidebarRow(_ provider: ConnectionProvider) -> some View { private func sidebarRow(_ channel: ConnectionChannel) -> some View {
let isSelected = self.selectedProvider == provider let isSelected = self.selectedChannel == channel
return Button { return Button {
self.selectedProvider = provider self.selectedChannel = channel
} label: { } label: {
HStack(spacing: 8) { HStack(spacing: 8) {
Circle() Circle()
.fill(self.providerTint(provider)) .fill(self.channelTint(channel))
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(provider.title) Text(channel.title)
Text(self.providerSummary(provider)) Text(self.channelSummary(channel))
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -119,23 +119,23 @@ extension ConnectionsSettings {
.padding(.top, 2) .padding(.top, 2)
} }
private func detailHeader(for provider: ConnectionProvider) -> some View { private func detailHeader(for channel: ConnectionChannel) -> some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 10) { HStack(alignment: .firstTextBaseline, spacing: 10) {
Label(provider.detailTitle, systemImage: provider.systemImage) Label(channel.detailTitle, systemImage: channel.systemImage)
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
self.statusBadge( self.statusBadge(
self.providerSummary(provider), self.channelSummary(channel),
color: self.providerTint(provider)) color: self.channelTint(channel))
Spacer() Spacer()
self.providerHeaderActions(provider) self.channelHeaderActions(channel)
} }
HStack(spacing: 10) { HStack(spacing: 10) {
Text("Last check \(self.providerLastCheckText(provider))") Text("Last check \(self.channelLastCheckText(channel))")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if self.providerHasError(provider) { if self.channelHasError(channel) {
Text("Error") Text("Error")
.font(.caption2.weight(.semibold)) .font(.caption2.weight(.semibold))
.padding(.horizontal, 6) .padding(.horizontal, 6)
@@ -146,7 +146,7 @@ extension ConnectionsSettings {
} }
} }
if let details = self.providerDetails(provider) { if let details = self.channelDetails(channel) {
Text(details) Text(details)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)

View File

@@ -2,7 +2,7 @@ import AppKit
import SwiftUI import SwiftUI
struct ConnectionsSettings: View { struct ConnectionsSettings: View {
enum ConnectionProvider: String, CaseIterable, Identifiable, Hashable { enum ConnectionChannel: String, CaseIterable, Identifiable, Hashable {
case whatsapp case whatsapp
case telegram case telegram
case discord case discord
@@ -53,7 +53,7 @@ struct ConnectionsSettings: View {
} }
@Bindable var store: ConnectionsStore @Bindable var store: ConnectionsStore
@State var selectedProvider: ConnectionProvider? @State var selectedChannel: ConnectionChannel?
@State var showTelegramToken = false @State var showTelegramToken = false
@State var showDiscordToken = false @State var showDiscordToken = false

View File

@@ -31,8 +31,8 @@ extension ConnectionsStore {
"probe": AnyCodable(probe), "probe": AnyCodable(probe),
"timeoutMs": AnyCodable(8000), "timeoutMs": AnyCodable(8000),
] ]
let snap: ProvidersStatusSnapshot = try await GatewayConnection.shared.requestDecoded( let snap: ChannelsStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .providersStatus, method: .channelsStatus,
params: params, params: params,
timeoutMs: 12000) timeoutMs: 12000)
self.snapshot = snap self.snapshot = snap
@@ -101,10 +101,10 @@ extension ConnectionsStore {
defer { self.whatsappBusy = false } defer { self.whatsappBusy = false }
do { do {
let params: [String: AnyCodable] = [ let params: [String: AnyCodable] = [
"provider": AnyCodable("whatsapp"), "channel": AnyCodable("whatsapp"),
] ]
let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded( let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .providersLogout, method: .channelsLogout,
params: params, params: params,
timeoutMs: 15000) timeoutMs: 15000)
self.whatsappLoginMessage = result.cleared self.whatsappLoginMessage = result.cleared
@@ -123,10 +123,10 @@ extension ConnectionsStore {
defer { self.telegramBusy = false } defer { self.telegramBusy = false }
do { do {
let params: [String: AnyCodable] = [ let params: [String: AnyCodable] = [
"provider": AnyCodable("telegram"), "channel": AnyCodable("telegram"),
] ]
let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded( let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .providersLogout, method: .channelsLogout,
params: params, params: params,
timeoutMs: 15000) timeoutMs: 15000)
if result.envToken == true { if result.envToken == true {
@@ -154,8 +154,8 @@ private struct WhatsAppLoginWaitResult: Codable {
let message: String let message: String
} }
private struct ProviderLogoutResult: Codable { private struct ChannelLogoutResult: Codable {
let provider: String? let channel: String?
let accountId: String? let accountId: String?
let cleared: Bool let cleared: Bool
let envToken: Bool? let envToken: Bool?

View File

@@ -2,7 +2,7 @@ import ClawdbotProtocol
import Foundation import Foundation
import Observation import Observation
struct ProvidersStatusSnapshot: Codable { struct ChannelsStatusSnapshot: Codable {
struct WhatsAppSelf: Codable { struct WhatsAppSelf: Codable {
let e164: String? let e164: String?
let jid: String? let jid: String?
@@ -121,7 +121,7 @@ struct ProvidersStatusSnapshot: Codable {
let lastProbeAt: Double? let lastProbeAt: Double?
} }
struct ProviderAccountSnapshot: Codable { struct ChannelAccountSnapshot: Codable {
let accountId: String let accountId: String
let name: String? let name: String?
let enabled: Bool? let enabled: Bool?
@@ -154,14 +154,14 @@ struct ProvidersStatusSnapshot: Codable {
} }
let ts: Double let ts: Double
let providerOrder: [String] let channelOrder: [String]
let providerLabels: [String: String] let channelLabels: [String: String]
let providers: [String: AnyCodable] let channels: [String: AnyCodable]
let providerAccounts: [String: [ProviderAccountSnapshot]] let channelAccounts: [String: [ChannelAccountSnapshot]]
let providerDefaultAccountId: [String: String] let channelDefaultAccountId: [String: String]
func decodeProvider<T: Decodable>(_ id: String, as type: T.Type) -> T? { func decodeChannel<T: Decodable>(_ id: String, as type: T.Type) -> T? {
guard let value = self.providers[id] else { return nil } guard let value = self.channels[id] else { return nil }
do { do {
let data = try JSONEncoder().encode(value) let data = try JSONEncoder().encode(value)
return try JSONDecoder().decode(type, from: data) return try JSONDecoder().decode(type, from: data)
@@ -230,7 +230,7 @@ struct DiscordGuildForm: Identifiable {
final class ConnectionsStore { final class ConnectionsStore {
static let shared = ConnectionsStore() static let shared = ConnectionsStore()
var snapshot: ProvidersStatusSnapshot? var snapshot: ChannelsStatusSnapshot?
var lastError: String? var lastError: String?
var lastSuccess: Date? var lastSuccess: Date?
var isRefreshing = false var isRefreshing = false

View File

@@ -36,13 +36,13 @@ extension CronJobEditor {
case let .systemEvent(text): case let .systemEvent(text):
self.payloadKind = .systemEvent self.payloadKind = .systemEvent
self.systemEventText = text self.systemEventText = text
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver): case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
self.payloadKind = .agentTurn self.payloadKind = .agentTurn
self.agentMessage = message self.agentMessage = message
self.thinking = thinking ?? "" self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? "" self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false self.deliver = deliver ?? false
self.provider = GatewayAgentProvider(raw: provider) self.channel = GatewayAgentChannel(raw: channel)
self.to = to ?? "" self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false self.bestEffortDeliver = bestEffortDeliver ?? false
} }
@@ -204,7 +204,7 @@ extension CronJobEditor {
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n } if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver payload["deliver"] = self.deliver
if self.deliver { if self.deliver {
payload["provider"] = self.provider.rawValue payload["channel"] = self.channel.rawValue
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines) let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to } if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver payload["bestEffortDeliver"] = self.bestEffortDeliver

View File

@@ -14,7 +14,7 @@ extension CronJobEditor {
self.payloadKind = .agentTurn self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic" self.agentMessage = "Run diagnostic"
self.deliver = true self.deliver = true
self.provider = .last self.channel = .last
self.to = "+15551230000" self.to = "+15551230000"
self.thinking = "low" self.thinking = "low"
self.timeoutSeconds = "90" self.timeoutSeconds = "90"

View File

@@ -18,7 +18,7 @@ struct CronJobEditor: View {
static let scheduleKindNote = static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
static let isolatedPayloadNote = static let isolatedPayloadNote =
"Isolated jobs always run an agent turn. The result can be delivered to a provider, " "Isolated jobs always run an agent turn. The result can be delivered to a channel, "
+ "and a short summary is posted back to your main chat." + "and a short summary is posted back to your main chat."
static let mainPayloadNote = static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target." "System events are injected into the current main session. Agent turns require an isolated session target."
@@ -45,7 +45,7 @@ struct CronJobEditor: View {
@State var systemEventText: String = "" @State var systemEventText: String = ""
@State var agentMessage: String = "" @State var agentMessage: String = ""
@State var deliver: Bool = false @State var deliver: Bool = false
@State var provider: GatewayAgentProvider = .last @State var channel: GatewayAgentChannel = .last
@State var to: String = "" @State var to: String = ""
@State var thinking: String = "" @State var thinking: String = ""
@State var timeoutSeconds: String = "" @State var timeoutSeconds: String = ""
@@ -323,7 +323,7 @@ struct CronJobEditor: View {
} }
GridRow { GridRow {
self.gridLabel("Deliver") self.gridLabel("Deliver")
Toggle("Deliver result to a provider", isOn: self.$deliver) Toggle("Deliver result to a channel", isOn: self.$deliver)
.toggleStyle(.switch) .toggleStyle(.switch)
} }
} }
@@ -331,15 +331,15 @@ struct CronJobEditor: View {
if self.deliver { if self.deliver {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow { GridRow {
self.gridLabel("Provider") self.gridLabel("Channel")
Picker("", selection: self.$provider) { Picker("", selection: self.$channel) {
Text("last").tag(GatewayAgentProvider.last) Text("last").tag(GatewayAgentChannel.last)
Text("whatsapp").tag(GatewayAgentProvider.whatsapp) Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
Text("telegram").tag(GatewayAgentProvider.telegram) Text("telegram").tag(GatewayAgentChannel.telegram)
Text("discord").tag(GatewayAgentProvider.discord) Text("discord").tag(GatewayAgentChannel.discord)
Text("slack").tag(GatewayAgentProvider.slack) Text("slack").tag(GatewayAgentChannel.slack)
Text("signal").tag(GatewayAgentProvider.signal) Text("signal").tag(GatewayAgentChannel.signal)
Text("imessage").tag(GatewayAgentProvider.imessage) Text("imessage").tag(GatewayAgentChannel.imessage)
} }
.labelsHidden() .labelsHidden()
.pickerStyle(.segmented) .pickerStyle(.segmented)

View File

@@ -67,20 +67,20 @@ enum CronSchedule: Codable, Equatable {
} }
} }
enum CronPayload: Codable, Equatable { enum CronPayload: Codable, Equatable {
case systemEvent(text: String) case systemEvent(text: String)
case agentTurn( case agentTurn(
message: String, message: String,
thinking: String?, thinking: String?,
timeoutSeconds: Int?, timeoutSeconds: Int?,
deliver: Bool?, deliver: Bool?,
provider: String?, channel: String?,
to: String?, to: String?,
bestEffortDeliver: Bool?) bestEffortDeliver: Bool?)
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case kind, text, message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver
} }
var kind: String { var kind: String {
switch self { switch self {
@@ -95,15 +95,16 @@ enum CronPayload: Codable, Equatable {
switch kind { switch kind {
case "systemEvent": case "systemEvent":
self = try .systemEvent(text: container.decode(String.self, forKey: .text)) self = try .systemEvent(text: container.decode(String.self, forKey: .text))
case "agentTurn": case "agentTurn":
self = try .agentTurn( self = try .agentTurn(
message: container.decode(String.self, forKey: .message), message: container.decode(String.self, forKey: .message),
thinking: container.decodeIfPresent(String.self, forKey: .thinking), thinking: container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds), timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver), deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
provider: container.decodeIfPresent(String.self, forKey: .provider), channel: container.decodeIfPresent(String.self, forKey: .channel)
to: container.decodeIfPresent(String.self, forKey: .to), ?? container.decodeIfPresent(String.self, forKey: .provider),
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)) to: container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
default: default:
throw DecodingError.dataCorruptedError( throw DecodingError.dataCorruptedError(
forKey: .kind, forKey: .kind,
@@ -118,17 +119,17 @@ enum CronPayload: Codable, Equatable {
switch self { switch self {
case let .systemEvent(text): case let .systemEvent(text):
try container.encode(text, forKey: .text) try container.encode(text, forKey: .text)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver): case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
try container.encode(message, forKey: .message) try container.encode(message, forKey: .message)
try container.encodeIfPresent(thinking, forKey: .thinking) try container.encodeIfPresent(thinking, forKey: .thinking)
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds) try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try container.encodeIfPresent(deliver, forKey: .deliver) try container.encodeIfPresent(deliver, forKey: .deliver)
try container.encodeIfPresent(provider, forKey: .provider) try container.encodeIfPresent(channel, forKey: .channel)
try container.encodeIfPresent(to, forKey: .to) try container.encodeIfPresent(to, forKey: .to)
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver) try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
} }
} }
} }
struct CronIsolation: Codable, Equatable { struct CronIsolation: Codable, Equatable {
var postToMainPrefix: String? var postToMainPrefix: String?

View File

@@ -59,7 +59,7 @@ final class DeepLinkHandler {
} }
do { do {
let provider = GatewayAgentProvider(raw: link.channel) let channel = GatewayAgentChannel(raw: link.channel)
let explicitSessionKey = link.sessionKey? let explicitSessionKey = link.sessionKey?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty .nonEmpty
@@ -72,9 +72,9 @@ final class DeepLinkHandler {
message: messagePreview, message: messagePreview,
sessionKey: resolvedSessionKey, sessionKey: resolvedSessionKey,
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
deliver: provider.shouldDeliver(link.deliver), deliver: channel.shouldDeliver(link.deliver),
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
provider: provider, channel: channel,
timeoutSeconds: link.timeoutSeconds, timeoutSeconds: link.timeoutSeconds,
idempotencyKey: UUID().uuidString) idempotencyKey: UUID().uuidString)

View File

@@ -5,7 +5,7 @@ import OSLog
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection") private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection")
enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable { enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case last case last
case whatsapp case whatsapp
case telegram case telegram
@@ -18,7 +18,7 @@ enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
init(raw: String?) { init(raw: String?) {
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
self = GatewayAgentProvider(rawValue: normalized) ?? .last self = GatewayAgentChannel(rawValue: normalized) ?? .last
} }
var isDeliverable: Bool { self != .webchat } var isDeliverable: Bool { self != .webchat }
@@ -32,7 +32,7 @@ struct GatewayAgentInvocation: Sendable {
var thinking: String? var thinking: String?
var deliver: Bool = false var deliver: Bool = false
var to: String? var to: String?
var provider: GatewayAgentProvider = .last var channel: GatewayAgentChannel = .last
var timeoutSeconds: Int? var timeoutSeconds: Int?
var idempotencyKey: String = UUID().uuidString var idempotencyKey: String = UUID().uuidString
} }
@@ -52,7 +52,7 @@ actor GatewayConnection {
case setHeartbeats = "set-heartbeats" case setHeartbeats = "set-heartbeats"
case systemEvent = "system-event" case systemEvent = "system-event"
case health case health
case providersStatus = "providers.status" case channelsStatus = "channels.status"
case configGet = "config.get" case configGet = "config.get"
case configSet = "config.set" case configSet = "config.set"
case wizardStart = "wizard.start" case wizardStart = "wizard.start"
@@ -62,7 +62,7 @@ actor GatewayConnection {
case talkMode = "talk.mode" case talkMode = "talk.mode"
case webLoginStart = "web.login.start" case webLoginStart = "web.login.start"
case webLoginWait = "web.login.wait" case webLoginWait = "web.login.wait"
case providersLogout = "providers.logout" case channelsLogout = "channels.logout"
case modelsList = "models.list" case modelsList = "models.list"
case chatHistory = "chat.history" case chatHistory = "chat.history"
case chatSend = "chat.send" case chatSend = "chat.send"
@@ -368,7 +368,7 @@ extension GatewayConnection {
"thinking": AnyCodable(invocation.thinking ?? "default"), "thinking": AnyCodable(invocation.thinking ?? "default"),
"deliver": AnyCodable(invocation.deliver), "deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""), "to": AnyCodable(invocation.to ?? ""),
"provider": AnyCodable(invocation.provider.rawValue), "channel": AnyCodable(invocation.channel.rawValue),
"idempotencyKey": AnyCodable(invocation.idempotencyKey), "idempotencyKey": AnyCodable(invocation.idempotencyKey),
] ]
if let timeout = invocation.timeoutSeconds { if let timeout = invocation.timeoutSeconds {
@@ -389,7 +389,7 @@ extension GatewayConnection {
sessionKey: String, sessionKey: String,
deliver: Bool, deliver: Bool,
to: String?, to: String?,
provider: GatewayAgentProvider = .last, channel: GatewayAgentChannel = .last,
timeoutSeconds: Int? = nil, timeoutSeconds: Int? = nil,
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?) idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
{ {
@@ -399,7 +399,7 @@ extension GatewayConnection {
thinking: thinking, thinking: thinking,
deliver: deliver, deliver: deliver,
to: to, to: to,
provider: provider, channel: channel,
timeoutSeconds: timeoutSeconds, timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey)) idempotencyKey: idempotencyKey))
} }

View File

@@ -211,19 +211,19 @@ final class GatewayProcessManager {
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String { private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
let instanceText = instance ?? "pid unknown" let instanceText = instance ?? "pid unknown"
if let snap { if let snap {
let linkId = snap.providerOrder?.first(where: { let linkId = snap.channelOrder?.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil } if let summary = snap.channels[$0] { return summary.linked != nil }
return false return false
}) ?? snap.providers.keys.first(where: { }) ?? snap.channels.keys.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil } if let summary = snap.channels[$0] { return summary.linked != nil }
return false return false
}) })
let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
let authAge = linkId.flatMap { snap.providers[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age" let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
let label = let label =
linkId.flatMap { snap.providerLabels?[$0] } ?? linkId.flatMap { snap.channelLabels?[$0] } ??
linkId?.capitalized ?? linkId?.capitalized ??
"provider" "channel"
let linkText = linked ? "linked" : "not linked" let linkText = linked ? "linked" : "not linked"
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)" return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
} }

View File

@@ -496,18 +496,18 @@ struct GeneralSettings: View {
} }
if let snap = snapshot { if let snap = snapshot {
let linkId = snap.providerOrder?.first(where: { let linkId = snap.channelOrder?.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil } if let summary = snap.channels[$0] { return summary.linked != nil }
return false return false
}) ?? snap.providers.keys.first(where: { }) ?? snap.channels.keys.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil } if let summary = snap.channels[$0] { return summary.linked != nil }
return false return false
}) })
let linkLabel = let linkLabel =
linkId.flatMap { snap.providerLabels?[$0] } ?? linkId.flatMap { snap.channelLabels?[$0] } ??
linkId?.capitalized ?? linkId?.capitalized ??
"Link provider" "Link channel"
let linkAge = linkId.flatMap { snap.providers[$0]?.authAgeMs } let linkAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))") Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)

View File

@@ -4,7 +4,7 @@ import Observation
import SwiftUI import SwiftUI
struct HealthSnapshot: Codable, Sendable { struct HealthSnapshot: Codable, Sendable {
struct ProviderSummary: Codable, Sendable { struct ChannelSummary: Codable, Sendable {
struct Probe: Codable, Sendable { struct Probe: Codable, Sendable {
struct Bot: Codable, Sendable { struct Bot: Codable, Sendable {
let username: String? let username: String?
@@ -44,9 +44,9 @@ struct HealthSnapshot: Codable, Sendable {
let ok: Bool? let ok: Bool?
let ts: Double let ts: Double
let durationMs: Double let durationMs: Double
let providers: [String: ProviderSummary] let channels: [String: ChannelSummary]
let providerOrder: [String]? let channelOrder: [String]?
let providerLabels: [String: String]? let channelLabels: [String: String]?
let heartbeatSeconds: Int? let heartbeatSeconds: Int?
let sessions: Sessions let sessions: Sessions
} }
@@ -144,13 +144,13 @@ final class HealthStore {
} }
} }
private static func isProviderHealthy(_ summary: HealthSnapshot.ProviderSummary) -> Bool { private static func isChannelHealthy(_ summary: HealthSnapshot.ChannelSummary) -> Bool {
guard summary.configured == true else { return false } guard summary.configured == true else { return false }
// If probe is missing, treat it as "configured but unknown health" (not a hard fail). // If probe is missing, treat it as "configured but unknown health" (not a hard fail).
return summary.probe?.ok ?? true return summary.probe?.ok ?? true
} }
private static func describeProbeFailure(_ probe: HealthSnapshot.ProviderSummary.Probe) -> String { private static func describeProbeFailure(_ probe: HealthSnapshot.ChannelSummary.Probe) -> String {
let elapsed = probe.elapsedMs.map { "\(Int($0))ms" } let elapsed = probe.elapsedMs.map { "\(Int($0))ms" }
if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil { if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil {
if let elapsed { return "Health check timed out (\(elapsed))" } if let elapsed { return "Health check timed out (\(elapsed))" }
@@ -162,28 +162,28 @@ final class HealthStore {
return "\(reason) (\(code))" return "\(reason) (\(code))"
} }
private func resolveLinkProvider( private func resolveLinkChannel(
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ProviderSummary)? _ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
{ {
let order = snap.providerOrder ?? Array(snap.providers.keys) let order = snap.channelOrder ?? Array(snap.channels.keys)
for id in order { for id in order {
if let summary = snap.providers[id], summary.linked != nil { if let summary = snap.channels[id], summary.linked != nil {
return (id: id, summary: summary) return (id: id, summary: summary)
} }
} }
return nil return nil
} }
private func resolveFallbackProvider( private func resolveFallbackChannel(
_ snap: HealthSnapshot, _ snap: HealthSnapshot,
excluding id: String?) -> (id: String, summary: HealthSnapshot.ProviderSummary)? excluding id: String?) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
{ {
let order = snap.providerOrder ?? Array(snap.providers.keys) let order = snap.channelOrder ?? Array(snap.channels.keys)
for providerId in order { for channelId in order {
if providerId == id { continue } if channelId == id { continue }
guard let summary = snap.providers[providerId] else { continue } guard let summary = snap.channels[channelId] else { continue }
if Self.isProviderHealthy(summary) { if Self.isChannelHealthy(summary) {
return (id: providerId, summary: summary) return (id: channelId, summary: summary)
} }
} }
return nil return nil
@@ -194,13 +194,13 @@ final class HealthStore {
return .degraded(error) return .degraded(error)
} }
guard let snap = self.snapshot else { return .unknown } guard let snap = self.snapshot else { return .unknown }
guard let link = self.resolveLinkProvider(snap) else { return .unknown } guard let link = self.resolveLinkChannel(snap) else { return .unknown }
if link.summary.linked != true { if link.summary.linked != true {
// Linking is optional if any other provider is healthy; don't paint the whole app red. // Linking is optional if any other channel is healthy; don't paint the whole app red.
let fallback = self.resolveFallbackProvider(snap, excluding: link.id) let fallback = self.resolveFallbackChannel(snap, excluding: link.id)
return fallback != nil ? .degraded("Not linked") : .linkingNeeded return fallback != nil ? .degraded("Not linked") : .linkingNeeded
} }
// A provider can be "linked" but still unhealthy (failed probe / cannot connect). // A channel can be "linked" but still unhealthy (failed probe / cannot connect).
if let probe = link.summary.probe, probe.ok == false { if let probe = link.summary.probe, probe.ok == false {
return .degraded(Self.describeProbeFailure(probe)) return .degraded(Self.describeProbeFailure(probe))
} }
@@ -211,10 +211,10 @@ final class HealthStore {
if self.isRefreshing { return "Health check running…" } if self.isRefreshing { return "Health check running…" }
if let error = self.lastError { return "Health check failed: \(error)" } if let error = self.lastError { return "Health check failed: \(error)" }
guard let snap = self.snapshot else { return "Health check pending" } guard let snap = self.snapshot else { return "Health check pending" }
guard let link = self.resolveLinkProvider(snap) else { return "Health check pending" } guard let link = self.resolveLinkChannel(snap) else { return "Health check pending" }
if link.summary.linked != true { if link.summary.linked != true {
if let fallback = self.resolveFallbackProvider(snap, excluding: link.id) { if let fallback = self.resolveFallbackChannel(snap, excluding: link.id) {
let fallbackLabel = snap.providerLabels?[fallback.id] ?? fallback.id.capitalized let fallbackLabel = snap.channelLabels?[fallback.id] ?? fallback.id.capitalized
let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded" let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded"
return "\(fallbackLabel) \(fallbackState) · Not linked — run clawdbot login" return "\(fallbackLabel) \(fallbackState) · Not linked — run clawdbot login"
} }
@@ -247,10 +247,10 @@ final class HealthStore {
} }
func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String { func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String {
if let link = self.resolveLinkProvider(snap), link.summary.linked != true { if let link = self.resolveLinkChannel(snap), link.summary.linked != true {
return "Not linked — run clawdbot login" return "Not linked — run clawdbot login"
} }
if let link = self.resolveLinkProvider(snap), let probe = link.summary.probe, probe.ok == false { if let link = self.resolveLinkChannel(snap), let probe = link.summary.probe, probe.ok == false {
return Self.describeProbeFailure(probe) return Self.describeProbeFailure(probe)
} }
if let fallback, !fallback.isEmpty { if let fallback, !fallback.isEmpty {

View File

@@ -242,18 +242,18 @@ final class InstancesStore {
do { do {
let data = try await ControlChannel.shared.health(timeout: 8) let data = try await ControlChannel.shared.health(timeout: 8)
guard let snap = decodeHealthSnapshot(from: data) else { return } guard let snap = decodeHealthSnapshot(from: data) else { return }
let linkId = snap.providerOrder?.first(where: { let linkId = snap.channelOrder?.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil } if let summary = snap.channels[$0] { return summary.linked != nil }
return false return false
}) ?? snap.providers.keys.first(where: { }) ?? snap.channels.keys.first(where: {
if let summary = snap.providers[$0] { return summary.linked != nil } if let summary = snap.channels[$0] { return summary.linked != nil }
return false return false
}) })
let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
let linkLabel = let linkLabel =
linkId.flatMap { snap.providerLabels?[$0] } ?? linkId.flatMap { snap.channelLabels?[$0] } ??
linkId?.capitalized ?? linkId?.capitalized ??
"provider" "channel"
let entry = InstanceInfo( let entry = InstanceInfo(
id: "health-\(snap.ts)", id: "health-\(snap.ts)",
host: "gateway (health)", host: "gateway (health)",

View File

@@ -694,7 +694,7 @@ extension OnboardingView {
systemImage: "bubble.left.and.bubble.right") systemImage: "bubble.left.and.bubble.right")
self.featureActionRow( self.featureActionRow(
title: "Connect WhatsApp or Telegram", title: "Connect WhatsApp or Telegram",
subtitle: "Open Settings → Connections to link providers and monitor status.", subtitle: "Open Settings → Connections to link channels and monitor status.",
systemImage: "link") systemImage: "link")
{ {
self.openSettings(tab: .connections) self.openSettings(tab: .connections)

View File

@@ -37,7 +37,7 @@ enum VoiceWakeForwarder {
var thinking: String = "low" var thinking: String = "low"
var deliver: Bool = true var deliver: Bool = true
var to: String? var to: String?
var provider: GatewayAgentProvider = .last var channel: GatewayAgentChannel = .last
} }
@discardableResult @discardableResult
@@ -46,14 +46,14 @@ enum VoiceWakeForwarder {
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError> options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
{ {
let payload = Self.prefixedTranscript(transcript) let payload = Self.prefixedTranscript(transcript)
let deliver = options.provider.shouldDeliver(options.deliver) let deliver = options.channel.shouldDeliver(options.deliver)
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: payload, message: payload,
sessionKey: options.sessionKey, sessionKey: options.sessionKey,
thinking: options.thinking, thinking: options.thinking,
deliver: deliver, deliver: deliver,
to: options.to, to: options.to,
provider: options.provider)) channel: options.channel))
if result.ok { if result.ok {
self.logger.info("voice wake forward ok") self.logger.info("voice wake forward ok")

View File

@@ -4,22 +4,22 @@ import Testing
@Suite(.serialized) @Suite(.serialized)
@MainActor @MainActor
struct ConnectionsSettingsSmokeTests { struct ConnectionsSettingsSmokeTests {
@Test func connectionsSettingsBuildsBodyWithSnapshot() { @Test func connectionsSettingsBuildsBodyWithSnapshot() {
let store = ConnectionsStore(isPreview: true) let store = ConnectionsStore(isPreview: true)
store.snapshot = ProvidersStatusSnapshot( store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000, ts: 1_700_000_000_000,
providerOrder: ["whatsapp", "telegram", "signal", "imessage"], channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
providerLabels: [ channelLabels: [
"whatsapp": "WhatsApp", "whatsapp": "WhatsApp",
"telegram": "Telegram", "telegram": "Telegram",
"signal": "Signal", "signal": "Signal",
"imessage": "iMessage", "imessage": "iMessage",
], ],
providers: [ channels: [
"whatsapp": AnyCodable([ "whatsapp": AnyCodable([
"configured": true, "configured": true,
"linked": true, "linked": true,
"authAgeMs": 86_400_000, "authAgeMs": 86_400_000,
"self": ["e164": "+15551234567"], "self": ["e164": "+15551234567"],
"running": true, "running": true,
@@ -70,13 +70,13 @@ struct ConnectionsSettingsSmokeTests {
"lastError": "not configured", "lastError": "not configured",
"probe": ["ok": false, "error": "imsg not found (imsg)"], "probe": ["ok": false, "error": "imsg not found (imsg)"],
"lastProbeAt": 1_700_000_050_000, "lastProbeAt": 1_700_000_050_000,
]), ]),
], ],
providerAccounts: [:], channelAccounts: [:],
providerDefaultAccountId: [ channelDefaultAccountId: [
"whatsapp": "default", "whatsapp": "default",
"telegram": "default", "telegram": "default",
"signal": "default", "signal": "default",
"imessage": "default", "imessage": "default",
]) ])
@@ -93,23 +93,23 @@ struct ConnectionsSettingsSmokeTests {
let view = ConnectionsSettings(store: store) let view = ConnectionsSettings(store: store)
_ = view.body _ = view.body
} }
@Test func connectionsSettingsBuildsBodyWithoutSnapshot() { @Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
let store = ConnectionsStore(isPreview: true) let store = ConnectionsStore(isPreview: true)
store.snapshot = ProvidersStatusSnapshot( store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000, ts: 1_700_000_000_000,
providerOrder: ["whatsapp", "telegram", "signal", "imessage"], channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
providerLabels: [ channelLabels: [
"whatsapp": "WhatsApp", "whatsapp": "WhatsApp",
"telegram": "Telegram", "telegram": "Telegram",
"signal": "Signal", "signal": "Signal",
"imessage": "iMessage", "imessage": "iMessage",
], ],
providers: [ channels: [
"whatsapp": AnyCodable([ "whatsapp": AnyCodable([
"configured": false, "configured": false,
"linked": false, "linked": false,
"running": false, "running": false,
"connected": false, "connected": false,
"reconnectAttempts": 0, "reconnectAttempts": 0,
@@ -146,13 +146,13 @@ struct ConnectionsSettingsSmokeTests {
"cliPath": "imsg", "cliPath": "imsg",
"probe": ["ok": false, "error": "imsg not found (imsg)"], "probe": ["ok": false, "error": "imsg not found (imsg)"],
"lastProbeAt": 1_700_000_200_000, "lastProbeAt": 1_700_000_200_000,
]), ]),
], ],
providerAccounts: [:], channelAccounts: [:],
providerDefaultAccountId: [ channelDefaultAccountId: [
"whatsapp": "default", "whatsapp": "default",
"telegram": "default", "telegram": "default",
"signal": "default", "signal": "default",
"imessage": "default", "imessage": "default",
]) ])

View File

@@ -2,17 +2,17 @@ import Foundation
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite struct HealthDecodeTests { @Suite struct HealthDecodeTests {
private let sampleJSON: String = // minimal but complete payload private let sampleJSON: String = // minimal but complete payload
""" """
{"ts":1733622000,"durationMs":420,"providers":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"providerOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}} {"ts":1733622000,"durationMs":420,"channels":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"channelOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}}
""" """
@Test func decodesCleanJSON() async throws { @Test func decodesCleanJSON() async throws {
let data = Data(sampleJSON.utf8) let data = Data(sampleJSON.utf8)
let snap = decodeHealthSnapshot(from: data) let snap = decodeHealthSnapshot(from: data)
#expect(snap?.providers["whatsapp"]?.linked == true) #expect(snap?.channels["whatsapp"]?.linked == true)
#expect(snap?.sessions.count == 1) #expect(snap?.sessions.count == 1)
} }
@@ -20,7 +20,7 @@ import Testing
let noisy = "debug: something logged\n" + self.sampleJSON + "\ntrailer" let noisy = "debug: something logged\n" + self.sampleJSON + "\ntrailer"
let snap = decodeHealthSnapshot(from: Data(noisy.utf8)) let snap = decodeHealthSnapshot(from: Data(noisy.utf8))
#expect(snap?.providers["telegram"]?.probe?.elapsedMs == 800) #expect(snap?.channels["telegram"]?.probe?.elapsedMs == 800)
} }
@Test func failsWithoutBraces() async throws { @Test func failsWithoutBraces() async throws {

View File

@@ -3,12 +3,12 @@ import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite struct HealthStoreStateTests { @Suite struct HealthStoreStateTests {
@Test @MainActor func linkedProviderProbeFailureDegradesState() async throws { @Test @MainActor func linkedChannelProbeFailureDegradesState() async throws {
let snap = HealthSnapshot( let snap = HealthSnapshot(
ok: true, ok: true,
ts: 0, ts: 0,
durationMs: 1, durationMs: 1,
providers: [ channels: [
"whatsapp": .init( "whatsapp": .init(
configured: true, configured: true,
linked: true, linked: true,
@@ -22,8 +22,8 @@ import Testing
webhook: nil), webhook: nil),
lastProbeAt: 0), lastProbeAt: 0),
], ],
providerOrder: ["whatsapp"], channelOrder: ["whatsapp"],
providerLabels: ["whatsapp": "WhatsApp"], channelLabels: ["whatsapp": "WhatsApp"],
heartbeatSeconds: 60, heartbeatSeconds: 60,
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: [])) sessions: .init(path: "/tmp/sessions.json", count: 0, recent: []))
@@ -34,7 +34,7 @@ import Testing
case let .degraded(message): case let .degraded(message):
#expect(!message.isEmpty) #expect(!message.isEmpty)
default: default:
Issue.record("Expected degraded state when probe fails for linked provider") Issue.record("Expected degraded state when probe fails for linked channel")
} }
#expect(store.summaryLine.contains("probe degraded")) #expect(store.summaryLine.contains("probe degraded"))

View File

@@ -36,7 +36,7 @@ Short, exact flow of one agent run.
- `assistant`: streamed deltas from pi-agent-core - `assistant`: streamed deltas from pi-agent-core
- `tool`: streamed tool events from pi-agent-core - `tool`: streamed tool events from pi-agent-core
## Chat provider handling ## Chat channel handling
- Assistant deltas are buffered into chat `delta` messages. - Assistant deltas are buffered into chat `delta` messages.
- A chat `final` is emitted on **lifecycle end/error**. - A chat `final` is emitted on **lifecycle end/error**.

View File

@@ -6,7 +6,7 @@ read_when:
--- ---
# Model providers # Model providers
This page covers **LLM/model providers** (not chat providers like WhatsApp/Telegram). This page covers **LLM/model providers** (not chat channels like WhatsApp/Telegram).
For model selection rules, see [/concepts/models](/concepts/models). For model selection rules, see [/concepts/models](/concepts/models).
## Quick rules ## Quick rules

View File

@@ -51,7 +51,7 @@ incompatible, update the global CLI to match the app version.
```bash ```bash
clawdbot --version clawdbot --version
CLAWDBOT_SKIP_PROVIDERS=1 \ CLAWDBOT_SKIP_CHANNELS=1 \
CLAWDBOT_SKIP_CANVAS_HOST=1 \ CLAWDBOT_SKIP_CANVAS_HOST=1 \
clawdbot gateway --port 18999 --bind loopback clawdbot gateway --port 18999 --bind loopback
``` ```

View File

@@ -22,15 +22,15 @@ echo "Creating Docker network..."
docker network create "$NET_NAME" >/dev/null docker network create "$NET_NAME" >/dev/null
echo "Starting gateway container..." echo "Starting gateway container..."
docker run --rm -d \ docker run --rm -d \
--name "$GW_NAME" \ --name "$GW_NAME" \
--network "$NET_NAME" \ --network "$NET_NAME" \
-e "CLAWDBOT_GATEWAY_TOKEN=$TOKEN" \ -e "CLAWDBOT_GATEWAY_TOKEN=$TOKEN" \
-e "CLAWDBOT_SKIP_PROVIDERS=1" \ -e "CLAWDBOT_SKIP_CHANNELS=1" \
-e "CLAWDBOT_SKIP_GMAIL_WATCHER=1" \ -e "CLAWDBOT_SKIP_GMAIL_WATCHER=1" \
-e "CLAWDBOT_SKIP_CRON=1" \ -e "CLAWDBOT_SKIP_CRON=1" \
-e "CLAWDBOT_SKIP_CANVAS_HOST=1" \ -e "CLAWDBOT_SKIP_CANVAS_HOST=1" \
"$IMAGE_NAME" \ "$IMAGE_NAME" \
bash -lc "node dist/index.js gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1" bash -lc "node dist/index.js gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1"
echo "Waiting for gateway to come up..." echo "Waiting for gateway to come up..."

View File

@@ -445,11 +445,11 @@ function buildCollectPrompt(items: FollowupRun[], summary?: string): string {
/** /**
* Checks if queued items have different routable originating channels. * Checks if queued items have different routable originating channels.
* *
* Returns true if messages come from different providers (e.g., Slack + Telegram), * Returns true if messages come from different channels (e.g., Slack + Telegram),
* meaning they cannot be safely collected into one prompt without losing routing. * meaning they cannot be safely collected into one prompt without losing routing.
* Also returns true for a mix of routable and non-routable channels. * Also returns true for a mix of routable and non-routable channels.
*/ */
function hasCrossProviderItems(items: FollowupRun[]): boolean { function hasCrossChannelItems(items: FollowupRun[]): boolean {
const keys = new Set<string>(); const keys = new Set<string>();
let hasUnkeyed = false; let hasUnkeyed = false;
@@ -499,33 +499,33 @@ export function scheduleFollowupDrain(
if (forceIndividualCollect) { if (forceIndividualCollect) {
const next = queue.items.shift(); const next = queue.items.shift();
if (!next) break; if (!next) break;
await runFollowup(next); await runFollowup(next);
continue; continue;
} }
// Check if messages span multiple providers. // Check if messages span multiple channels.
// If so, process individually to preserve per-message routing. // If so, process individually to preserve per-message routing.
const isCrossProvider = hasCrossProviderItems(queue.items); const isCrossChannel = hasCrossChannelItems(queue.items);
if (isCrossProvider) { if (isCrossChannel) {
forceIndividualCollect = true; forceIndividualCollect = true;
// Process one at a time to preserve per-message routing info. // Process one at a time to preserve per-message routing info.
const next = queue.items.shift(); const next = queue.items.shift();
if (!next) break; if (!next) break;
await runFollowup(next); await runFollowup(next);
continue; continue;
} }
// Same-provider messages can be safely collected. // Same-channel messages can be safely collected.
const items = queue.items.splice(0, queue.items.length); const items = queue.items.splice(0, queue.items.length);
const summary = buildSummaryPrompt(queue); const summary = buildSummaryPrompt(queue);
const run = items.at(-1)?.run ?? queue.lastRun; const run = items.at(-1)?.run ?? queue.lastRun;
if (!run) break; if (!run) break;
// Preserve originating channel from items when collecting same-provider. // Preserve originating channel from items when collecting same-channel.
const originatingChannel = items.find( const originatingChannel = items.find(
(i) => i.originatingChannel, (i) => i.originatingChannel,
)?.originatingChannel; )?.originatingChannel;
const originatingTo = items.find( const originatingTo = items.find(
(i) => i.originatingTo, (i) => i.originatingTo,
)?.originatingTo; )?.originatingTo;

View File

@@ -108,7 +108,7 @@ describe("gateway SIGTERM", () => {
...process.env, ...process.env,
CLAWDBOT_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: stateDir,
CLAWDBOT_CONFIG_PATH: configPath, CLAWDBOT_CONFIG_PATH: configPath,
CLAWDBOT_SKIP_PROVIDERS: "1", CLAWDBOT_SKIP_CHANNELS: "1",
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1", CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
CLAWDBOT_SKIP_CANVAS_HOST: "1", CLAWDBOT_SKIP_CANVAS_HOST: "1",
// Avoid port collisions with other test processes that may also start a bridge server. // Avoid port collisions with other test processes that may also start a bridge server.

View File

@@ -96,21 +96,21 @@ async function connectReq(params: { url: string; token?: string }) {
describe("onboard (non-interactive): gateway auth", () => { describe("onboard (non-interactive): gateway auth", () => {
it("writes gateway token auth into config and gateway enforces it", async () => { it("writes gateway token auth into config and gateway enforces it", async () => {
const prev = { const prev = {
home: process.env.HOME, home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR, stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH, configPath: process.env.CLAWDBOT_CONFIG_PATH,
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS, skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON, skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
token: process.env.CLAWDBOT_GATEWAY_TOKEN, token: process.env.CLAWDBOT_GATEWAY_TOKEN,
}; };
process.env.CLAWDBOT_SKIP_PROVIDERS = "1"; process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1"; process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
delete process.env.CLAWDBOT_GATEWAY_TOKEN; delete process.env.CLAWDBOT_GATEWAY_TOKEN;
const tempHome = await fs.mkdtemp( const tempHome = await fs.mkdtemp(
@@ -186,7 +186,7 @@ describe("onboard (non-interactive): gateway auth", () => {
process.env.HOME = prev.home; process.env.HOME = prev.home;
process.env.CLAWDBOT_STATE_DIR = prev.stateDir; process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_SKIP_PROVIDERS = prev.skipProviders; process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;

View File

@@ -106,21 +106,21 @@ describe("onboard (non-interactive): lan bind auto-token", () => {
// Windows runner occasionally drops the temp config write in this flow; skip to keep CI green. // Windows runner occasionally drops the temp config write in this flow; skip to keep CI green.
return; return;
} }
const prev = { const prev = {
home: process.env.HOME, home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR, stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH, configPath: process.env.CLAWDBOT_CONFIG_PATH,
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS, skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON, skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
token: process.env.CLAWDBOT_GATEWAY_TOKEN, token: process.env.CLAWDBOT_GATEWAY_TOKEN,
}; };
process.env.CLAWDBOT_SKIP_PROVIDERS = "1"; process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1"; process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
delete process.env.CLAWDBOT_GATEWAY_TOKEN; delete process.env.CLAWDBOT_GATEWAY_TOKEN;
const tempHome = await fs.mkdtemp( const tempHome = await fs.mkdtemp(
@@ -215,7 +215,7 @@ describe("onboard (non-interactive): lan bind auto-token", () => {
process.env.HOME = prev.home; process.env.HOME = prev.home;
process.env.CLAWDBOT_STATE_DIR = prev.stateDir; process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_SKIP_PROVIDERS = prev.skipProviders; process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;

View File

@@ -27,22 +27,22 @@ async function getFreePort(): Promise<number> {
describe("onboard (non-interactive): remote gateway config", () => { describe("onboard (non-interactive): remote gateway config", () => {
it("writes gateway.remote url/token and callGateway uses them", async () => { it("writes gateway.remote url/token and callGateway uses them", async () => {
const prev = { const prev = {
home: process.env.HOME, home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR, stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH, configPath: process.env.CLAWDBOT_CONFIG_PATH,
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS, skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON, skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
token: process.env.CLAWDBOT_GATEWAY_TOKEN, token: process.env.CLAWDBOT_GATEWAY_TOKEN,
password: process.env.CLAWDBOT_GATEWAY_PASSWORD, password: process.env.CLAWDBOT_GATEWAY_PASSWORD,
}; };
process.env.CLAWDBOT_SKIP_PROVIDERS = "1"; process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1"; process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
delete process.env.CLAWDBOT_GATEWAY_TOKEN; delete process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_PASSWORD; delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
@@ -104,16 +104,16 @@ describe("onboard (non-interactive): remote gateway config", () => {
expect(health?.ok).toBe(true); expect(health?.ok).toBe(true);
} finally { } finally {
await server.close({ reason: "non-interactive remote test complete" }); await server.close({ reason: "non-interactive remote test complete" });
await fs.rm(tempHome, { recursive: true, force: true }); await fs.rm(tempHome, { recursive: true, force: true });
process.env.HOME = prev.home; process.env.HOME = prev.home;
process.env.CLAWDBOT_STATE_DIR = prev.stateDir; process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_SKIP_PROVIDERS = prev.skipProviders; process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token; process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
process.env.CLAWDBOT_GATEWAY_PASSWORD = prev.password; process.env.CLAWDBOT_GATEWAY_PASSWORD = prev.password;
} }
}, 60_000); }, 60_000);
}); });

View File

@@ -285,7 +285,7 @@ async function auditGatewayRuntime(
issues.push({ issues.push({
code: SERVICE_AUDIT_CODES.gatewayRuntimeBun, code: SERVICE_AUDIT_CODES.gatewayRuntimeBun,
message: message:
"Gateway service uses Bun; Bun is incompatible with WhatsApp + Telegram providers.", "Gateway service uses Bun; Bun is incompatible with WhatsApp + Telegram channels.",
detail: execPath, detail: execPath,
level: "recommended", level: "recommended",
}); });

View File

@@ -199,21 +199,21 @@ async function connectClient(params: { url: string; token: string }) {
describeLive("gateway live (cli backend)", () => { describeLive("gateway live (cli backend)", () => {
it("runs the agent pipeline against the local CLI backend", async () => { it("runs the agent pipeline against the local CLI backend", async () => {
const previous = { const previous = {
configPath: process.env.CLAWDBOT_CONFIG_PATH, configPath: process.env.CLAWDBOT_CONFIG_PATH,
token: process.env.CLAWDBOT_GATEWAY_TOKEN, token: process.env.CLAWDBOT_GATEWAY_TOKEN,
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS, skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON, skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
anthropicApiKey: process.env.ANTHROPIC_API_KEY, anthropicApiKey: process.env.ANTHROPIC_API_KEY,
anthropicApiKeyOld: process.env.ANTHROPIC_API_KEY_OLD, anthropicApiKeyOld: process.env.ANTHROPIC_API_KEY_OLD,
}; };
process.env.CLAWDBOT_SKIP_PROVIDERS = "1"; process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1"; process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
delete process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_API_KEY_OLD; delete process.env.ANTHROPIC_API_KEY_OLD;
@@ -444,9 +444,9 @@ describeLive("gateway live (cli backend)", () => {
if (previous.token === undefined) if (previous.token === undefined)
delete process.env.CLAWDBOT_GATEWAY_TOKEN; delete process.env.CLAWDBOT_GATEWAY_TOKEN;
else process.env.CLAWDBOT_GATEWAY_TOKEN = previous.token; else process.env.CLAWDBOT_GATEWAY_TOKEN = previous.token;
if (previous.skipProviders === undefined) if (previous.skipChannels === undefined)
delete process.env.CLAWDBOT_SKIP_PROVIDERS; delete process.env.CLAWDBOT_SKIP_CHANNELS;
else process.env.CLAWDBOT_SKIP_PROVIDERS = previous.skipProviders; else process.env.CLAWDBOT_SKIP_CHANNELS = previous.skipChannels;
if (previous.skipGmail === undefined) if (previous.skipGmail === undefined)
delete process.env.CLAWDBOT_SKIP_GMAIL_WATCHER; delete process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;
else process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previous.skipGmail; else process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previous.skipGmail;

View File

@@ -352,7 +352,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
const previous = { const previous = {
configPath: process.env.CLAWDBOT_CONFIG_PATH, configPath: process.env.CLAWDBOT_CONFIG_PATH,
token: process.env.CLAWDBOT_GATEWAY_TOKEN, token: process.env.CLAWDBOT_GATEWAY_TOKEN,
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS, skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON, skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
@@ -363,7 +363,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
let tempAgentDir: string | undefined; let tempAgentDir: string | undefined;
let tempStateDir: string | undefined; let tempStateDir: string | undefined;
process.env.CLAWDBOT_SKIP_PROVIDERS = "1"; process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1"; process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
@@ -776,7 +776,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
process.env.CLAWDBOT_CONFIG_PATH = previous.configPath; process.env.CLAWDBOT_CONFIG_PATH = previous.configPath;
process.env.CLAWDBOT_GATEWAY_TOKEN = previous.token; process.env.CLAWDBOT_GATEWAY_TOKEN = previous.token;
process.env.CLAWDBOT_SKIP_PROVIDERS = previous.skipProviders; process.env.CLAWDBOT_SKIP_CHANNELS = previous.skipChannels;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previous.skipGmail; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previous.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = previous.skipCron; process.env.CLAWDBOT_SKIP_CRON = previous.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = previous.skipCanvas; process.env.CLAWDBOT_SKIP_CANVAS_HOST = previous.skipCanvas;
@@ -895,13 +895,13 @@ describeLive("gateway live (dev agent, profile keys)", () => {
const previous = { const previous = {
configPath: process.env.CLAWDBOT_CONFIG_PATH, configPath: process.env.CLAWDBOT_CONFIG_PATH,
token: process.env.CLAWDBOT_GATEWAY_TOKEN, token: process.env.CLAWDBOT_GATEWAY_TOKEN,
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS, skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON, skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
}; };
process.env.CLAWDBOT_SKIP_PROVIDERS = "1"; process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1"; process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
@@ -1035,7 +1035,7 @@ describeLive("gateway live (dev agent, profile keys)", () => {
process.env.CLAWDBOT_CONFIG_PATH = previous.configPath; process.env.CLAWDBOT_CONFIG_PATH = previous.configPath;
process.env.CLAWDBOT_GATEWAY_TOKEN = previous.token; process.env.CLAWDBOT_GATEWAY_TOKEN = previous.token;
process.env.CLAWDBOT_SKIP_PROVIDERS = previous.skipProviders; process.env.CLAWDBOT_SKIP_CHANNELS = previous.skipChannels;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previous.skipGmail; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previous.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = previous.skipCron; process.env.CLAWDBOT_SKIP_CRON = previous.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = previous.skipCanvas; process.env.CLAWDBOT_SKIP_CANVAS_HOST = previous.skipCanvas;

View File

@@ -271,15 +271,15 @@ async function connectClient(params: { url: string; token: string }) {
describe("gateway (mock openai): tool calling", () => { describe("gateway (mock openai): tool calling", () => {
it("runs a Read tool call end-to-end via gateway agent loop", async () => { it("runs a Read tool call end-to-end via gateway agent loop", async () => {
const prev = { const prev = {
home: process.env.HOME, home: process.env.HOME,
configPath: process.env.CLAWDBOT_CONFIG_PATH, configPath: process.env.CLAWDBOT_CONFIG_PATH,
token: process.env.CLAWDBOT_GATEWAY_TOKEN, token: process.env.CLAWDBOT_GATEWAY_TOKEN,
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS, skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON, skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
}; };
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
const openaiResponsesUrl = "https://api.openai.com/v1/responses"; const openaiResponsesUrl = "https://api.openai.com/v1/responses";
@@ -321,14 +321,14 @@ describe("gateway (mock openai): tool calling", () => {
// TypeScript: Bun's fetch typing includes extra properties; keep this test portable. // TypeScript: Bun's fetch typing includes extra properties; keep this test portable.
(globalThis as unknown as { fetch: unknown }).fetch = fetchImpl; (globalThis as unknown as { fetch: unknown }).fetch = fetchImpl;
const tempHome = await fs.mkdtemp( const tempHome = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-gw-mock-home-"), path.join(os.tmpdir(), "clawdbot-gw-mock-home-"),
); );
process.env.HOME = tempHome; process.env.HOME = tempHome;
process.env.CLAWDBOT_SKIP_PROVIDERS = "1"; process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1"; process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
const token = `test-${randomUUID()}`; const token = `test-${randomUUID()}`;
process.env.CLAWDBOT_GATEWAY_TOKEN = token; process.env.CLAWDBOT_GATEWAY_TOKEN = token;
@@ -424,13 +424,13 @@ describe("gateway (mock openai): tool calling", () => {
await server.close({ reason: "mock openai test complete" }); await server.close({ reason: "mock openai test complete" });
await fs.rm(tempHome, { recursive: true, force: true }); await fs.rm(tempHome, { recursive: true, force: true });
(globalThis as unknown as { fetch: unknown }).fetch = originalFetch; (globalThis as unknown as { fetch: unknown }).fetch = originalFetch;
process.env.HOME = prev.home; process.env.HOME = prev.home;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token; process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
process.env.CLAWDBOT_SKIP_PROVIDERS = prev.skipProviders; process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
} }
}, 30_000); }, 30_000);
}); });

View File

@@ -172,21 +172,21 @@ type WizardNextPayload = {
describe("gateway wizard (e2e)", () => { describe("gateway wizard (e2e)", () => {
it("runs wizard over ws and writes auth token config", async () => { it("runs wizard over ws and writes auth token config", async () => {
const prev = { const prev = {
home: process.env.HOME, home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR, stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH, configPath: process.env.CLAWDBOT_CONFIG_PATH,
token: process.env.CLAWDBOT_GATEWAY_TOKEN, token: process.env.CLAWDBOT_GATEWAY_TOKEN,
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS, skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON, skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
}; };
process.env.CLAWDBOT_SKIP_PROVIDERS = "1"; process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1"; process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
delete process.env.CLAWDBOT_GATEWAY_TOKEN; delete process.env.CLAWDBOT_GATEWAY_TOKEN;
const tempHome = await fs.mkdtemp( const tempHome = await fs.mkdtemp(
@@ -282,7 +282,7 @@ describe("gateway wizard (e2e)", () => {
process.env.CLAWDBOT_STATE_DIR = prev.stateDir; process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token; process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
process.env.CLAWDBOT_SKIP_PROVIDERS = prev.skipProviders; process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;

View File

@@ -166,21 +166,21 @@ vi.mock("./config-reload.js", () => ({
installGatewayTestHooks(); installGatewayTestHooks();
describe("gateway hot reload", () => { describe("gateway hot reload", () => {
let prevSkipProviders: string | undefined; let prevSkipChannels: string | undefined;
let prevSkipGmail: string | undefined; let prevSkipGmail: string | undefined;
beforeEach(() => { beforeEach(() => {
prevSkipProviders = process.env.CLAWDBOT_SKIP_PROVIDERS; prevSkipChannels = process.env.CLAWDBOT_SKIP_CHANNELS;
prevSkipGmail = process.env.CLAWDBOT_SKIP_GMAIL_WATCHER; prevSkipGmail = process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;
process.env.CLAWDBOT_SKIP_PROVIDERS = "0"; process.env.CLAWDBOT_SKIP_CHANNELS = "0";
delete process.env.CLAWDBOT_SKIP_GMAIL_WATCHER; delete process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;
}); });
afterEach(() => { afterEach(() => {
if (prevSkipProviders === undefined) { if (prevSkipChannels === undefined) {
delete process.env.CLAWDBOT_SKIP_PROVIDERS; delete process.env.CLAWDBOT_SKIP_CHANNELS;
} else { } else {
process.env.CLAWDBOT_SKIP_PROVIDERS = prevSkipProviders; process.env.CLAWDBOT_SKIP_CHANNELS = prevSkipChannels;
} }
if (prevSkipGmail === undefined) { if (prevSkipGmail === undefined) {
delete process.env.CLAWDBOT_SKIP_GMAIL_WATCHER; delete process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;

View File

@@ -344,7 +344,7 @@ vi.mock("../commands/agent.js", () => ({
agentCommand, agentCommand,
})); }));
process.env.CLAWDBOT_SKIP_PROVIDERS = "1"; process.env.CLAWDBOT_SKIP_CHANNELS = "1";
let previousHome: string | undefined; let previousHome: string | undefined;
let tempHome: string | undefined; let tempHome: string | undefined;

View File

@@ -430,9 +430,9 @@ const SUBSYSTEM_COLOR_OVERRIDES: Record<
> = { > = {
"gmail-watcher": "blue", "gmail-watcher": "blue",
}; };
const SUBSYSTEM_PREFIXES_TO_DROP = ["gateway", "providers"] as const; const SUBSYSTEM_PREFIXES_TO_DROP = ["gateway", "channels", "providers"] as const;
const SUBSYSTEM_MAX_SEGMENTS = 2; const SUBSYSTEM_MAX_SEGMENTS = 2;
const PROVIDER_SUBSYSTEM_PREFIXES = new Set<string>(CHAT_CHANNEL_ORDER); const CHANNEL_SUBSYSTEM_PREFIXES = new Set<string>(CHAT_CHANNEL_ORDER);
function pickSubsystemColor( function pickSubsystemColor(
color: ChalkInstance, color: ChalkInstance,
@@ -461,7 +461,7 @@ function formatSubsystemForConsole(subsystem: string): string {
parts.shift(); parts.shift();
} }
if (parts.length === 0) return original; if (parts.length === 0) return original;
if (PROVIDER_SUBSYSTEM_PREFIXES.has(parts[0])) { if (CHANNEL_SUBSYSTEM_PREFIXES.has(parts[0])) {
return parts[0]; return parts[0];
} }
if (parts.length > SUBSYSTEM_MAX_SEGMENTS) { if (parts.length > SUBSYSTEM_MAX_SEGMENTS) {

View File

@@ -249,14 +249,14 @@ export async function runOnboardingWizard(
`Tailscale exposure: ${formatTailscale( `Tailscale exposure: ${formatTailscale(
quickstartGateway.tailscaleMode, quickstartGateway.tailscaleMode,
)}`, )}`,
"Direct to chat providers.", "Direct to chat channels.",
] ]
: [ : [
`Gateway port: ${DEFAULT_GATEWAY_PORT}`, `Gateway port: ${DEFAULT_GATEWAY_PORT}`,
"Gateway bind: Loopback (127.0.0.1)", "Gateway bind: Loopback (127.0.0.1)",
"Gateway auth: Token (default)", "Gateway auth: Token (default)",
"Tailscale exposure: Off", "Tailscale exposure: Off",
"Direct to chat providers.", "Direct to chat channels.",
]; ];
await prompter.note(quickstartLines.join("\n"), "QuickStart"); await prompter.note(quickstartLines.join("\n"), "QuickStart");
} }

View File

@@ -141,7 +141,7 @@ const spawnGatewayInstance = async (name: string): Promise<GatewayInstance> => {
CLAWDBOT_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: stateDir,
CLAWDBOT_GATEWAY_TOKEN: "", CLAWDBOT_GATEWAY_TOKEN: "",
CLAWDBOT_GATEWAY_PASSWORD: "", CLAWDBOT_GATEWAY_PASSWORD: "",
CLAWDBOT_SKIP_PROVIDERS: "1", CLAWDBOT_SKIP_CHANNELS: "1",
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1", CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
CLAWDBOT_SKIP_CANVAS_HOST: "1", CLAWDBOT_SKIP_CANVAS_HOST: "1",
CLAWDBOT_ENABLE_BRIDGE_IN_TESTS: "1", CLAWDBOT_ENABLE_BRIDGE_IN_TESTS: "1",

View File

@@ -21,7 +21,7 @@ import type {
LogEntry, LogEntry,
LogLevel, LogLevel,
PresenceEntry, PresenceEntry,
ProvidersStatusSnapshot, ChannelsStatusSnapshot,
SessionsListResult, SessionsListResult,
SkillStatusReport, SkillStatusReport,
StatusSummary, StatusSummary,
@@ -47,7 +47,7 @@ import { renderOverview } from "./views/overview";
import { renderSessions } from "./views/sessions"; import { renderSessions } from "./views/sessions";
import { renderSkills } from "./views/skills"; import { renderSkills } from "./views/skills";
import { import {
loadProviders, loadChannels,
updateDiscordForm, updateDiscordForm,
updateIMessageForm, updateIMessageForm,
updateSlackForm, updateSlackForm,
@@ -119,10 +119,10 @@ export type AppViewState = {
configUiHints: Record<string, unknown>; configUiHints: Record<string, unknown>;
configForm: Record<string, unknown> | null; configForm: Record<string, unknown> | null;
configFormMode: "form" | "raw"; configFormMode: "form" | "raw";
providersLoading: boolean; channelsLoading: boolean;
providersSnapshot: ProvidersStatusSnapshot | null; channelsSnapshot: ChannelsStatusSnapshot | null;
providersError: string | null; channelsError: string | null;
providersLastSuccess: number | null; channelsLastSuccess: number | null;
whatsappLoginMessage: string | null; whatsappLoginMessage: string | null;
whatsappLoginQrDataUrl: string | null; whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null; whatsappLoginConnected: boolean | null;
@@ -299,7 +299,7 @@ export function renderApp(state: AppViewState) {
sessionsCount, sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null, cronEnabled: state.cronStatus?.enabled ?? null,
cronNext, cronNext,
lastProvidersRefresh: state.providersLastSuccess, lastChannelsRefresh: state.channelsLastSuccess,
onSettingsChange: (next) => state.applySettings(next), onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next), onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => { onSessionKeyChange: (next) => {
@@ -320,10 +320,10 @@ export function renderApp(state: AppViewState) {
${state.tab === "connections" ${state.tab === "connections"
? renderConnections({ ? renderConnections({
connected: state.connected, connected: state.connected,
loading: state.providersLoading, loading: state.channelsLoading,
snapshot: state.providersSnapshot, snapshot: state.channelsSnapshot,
lastError: state.providersError, lastError: state.channelsError,
lastSuccessAt: state.providersLastSuccess, lastSuccessAt: state.channelsLastSuccess,
whatsappMessage: state.whatsappLoginMessage, whatsappMessage: state.whatsappLoginMessage,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl, whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected, whatsappConnected: state.whatsappLoginConnected,
@@ -347,7 +347,7 @@ export function renderApp(state: AppViewState) {
imessageForm: state.imessageForm, imessageForm: state.imessageForm,
imessageSaving: state.imessageSaving, imessageSaving: state.imessageSaving,
imessageStatus: state.imessageConfigStatus, imessageStatus: state.imessageConfigStatus,
onRefresh: (probe) => loadProviders(state, probe), onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force), onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(), onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(), onWhatsAppLogout: () => state.handleWhatsAppLogout(),

View File

@@ -33,7 +33,7 @@ import type {
LogEntry, LogEntry,
LogLevel, LogLevel,
PresenceEntry, PresenceEntry,
ProvidersStatusSnapshot, ChannelsStatusSnapshot,
SessionsListResult, SessionsListResult,
SkillStatusReport, SkillStatusReport,
StatusSummary, StatusSummary,
@@ -63,7 +63,7 @@ import {
updateConfigFormValue, updateConfigFormValue,
} from "./controllers/config"; } from "./controllers/config";
import { import {
loadProviders, loadChannels,
logoutWhatsApp, logoutWhatsApp,
saveDiscordConfig, saveDiscordConfig,
saveIMessageConfig, saveIMessageConfig,
@@ -188,7 +188,7 @@ const DEFAULT_CRON_FORM: CronFormState = {
payloadKind: "systemEvent", payloadKind: "systemEvent",
payloadText: "", payloadText: "",
deliver: false, deliver: false,
provider: "last", channel: "last",
to: "", to: "",
timeoutSeconds: "", timeoutSeconds: "",
postToMainPrefix: "", postToMainPrefix: "",
@@ -247,10 +247,10 @@ export class ClawdbotApp extends LitElement {
@state() configFormDirty = false; @state() configFormDirty = false;
@state() configFormMode: "form" | "raw" = "form"; @state() configFormMode: "form" | "raw" = "form";
@state() providersLoading = false; @state() channelsLoading = false;
@state() providersSnapshot: ProvidersStatusSnapshot | null = null; @state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
@state() providersError: string | null = null; @state() channelsError: string | null = null;
@state() providersLastSuccess: number | null = null; @state() channelsLastSuccess: number | null = null;
@state() whatsappLoginMessage: string | null = null; @state() whatsappLoginMessage: string | null = null;
@state() whatsappLoginQrDataUrl: string | null = null; @state() whatsappLoginQrDataUrl: string | null = null;
@state() whatsappLoginConnected: boolean | null = null; @state() whatsappLoginConnected: boolean | null = null;
@@ -1026,7 +1026,7 @@ export class ClawdbotApp extends LitElement {
async loadOverview() { async loadOverview() {
await Promise.all([ await Promise.all([
loadProviders(this, false), loadChannels(this, false),
loadPresence(this), loadPresence(this),
loadSessions(this), loadSessions(this),
loadCronStatus(this), loadCronStatus(this),
@@ -1035,7 +1035,7 @@ export class ClawdbotApp extends LitElement {
} }
private async loadConnections() { private async loadConnections() {
await Promise.all([loadProviders(this, true), loadConfig(this)]); await Promise.all([loadChannels(this, true), loadConfig(this)]);
} }
async loadCron() { async loadCron() {
@@ -1147,47 +1147,47 @@ export class ClawdbotApp extends LitElement {
async handleWhatsAppStart(force: boolean) { async handleWhatsAppStart(force: boolean) {
await startWhatsAppLogin(this, force); await startWhatsAppLogin(this, force);
await loadProviders(this, true); await loadChannels(this, true);
} }
async handleWhatsAppWait() { async handleWhatsAppWait() {
await waitWhatsAppLogin(this); await waitWhatsAppLogin(this);
await loadProviders(this, true); await loadChannels(this, true);
} }
async handleWhatsAppLogout() { async handleWhatsAppLogout() {
await logoutWhatsApp(this); await logoutWhatsApp(this);
await loadProviders(this, true); await loadChannels(this, true);
} }
async handleTelegramSave() { async handleTelegramSave() {
await saveTelegramConfig(this); await saveTelegramConfig(this);
await loadConfig(this); await loadConfig(this);
await loadProviders(this, true); await loadChannels(this, true);
} }
async handleDiscordSave() { async handleDiscordSave() {
await saveDiscordConfig(this); await saveDiscordConfig(this);
await loadConfig(this); await loadConfig(this);
await loadProviders(this, true); await loadChannels(this, true);
} }
async handleSlackSave() { async handleSlackSave() {
await saveSlackConfig(this); await saveSlackConfig(this);
await loadConfig(this); await loadConfig(this);
await loadProviders(this, true); await loadChannels(this, true);
} }
async handleSignalSave() { async handleSignalSave() {
await saveSignalConfig(this); await saveSignalConfig(this);
await loadConfig(this); await loadConfig(this);
await loadProviders(this, true); await loadChannels(this, true);
} }
async handleIMessageSave() { async handleIMessageSave() {
await saveIMessageConfig(this); await saveIMessageConfig(this);
await loadConfig(this); await loadConfig(this);
await loadProviders(this, true); await loadChannels(this, true);
} }
// Sidebar handlers for tool output viewing // Sidebar handlers for tool output viewing

View File

@@ -1,6 +1,6 @@
import type { GatewayBrowserClient } from "../gateway"; import type { GatewayBrowserClient } from "../gateway";
import { parseList } from "../format"; import { parseList } from "../format";
import type { ConfigSnapshot, ProvidersStatusSnapshot } from "../types"; import type { ChannelsStatusSnapshot, ConfigSnapshot } from "../types";
import { import {
defaultDiscordActions, defaultDiscordActions,
defaultSlackActions, defaultSlackActions,
@@ -18,10 +18,10 @@ import {
export type ConnectionsState = { export type ConnectionsState = {
client: GatewayBrowserClient | null; client: GatewayBrowserClient | null;
connected: boolean; connected: boolean;
providersLoading: boolean; channelsLoading: boolean;
providersSnapshot: ProvidersStatusSnapshot | null; channelsSnapshot: ChannelsStatusSnapshot | null;
providersError: string | null; channelsError: string | null;
providersLastSuccess: number | null; channelsLastSuccess: number | null;
whatsappLoginMessage: string | null; whatsappLoginMessage: string | null;
whatsappLoginQrDataUrl: string | null; whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null; whatsappLoginConnected: boolean | null;
@@ -48,22 +48,22 @@ export type ConnectionsState = {
configSnapshot: ConfigSnapshot | null; configSnapshot: ConfigSnapshot | null;
}; };
export async function loadProviders(state: ConnectionsState, probe: boolean) { export async function loadChannels(state: ConnectionsState, probe: boolean) {
if (!state.client || !state.connected) return; if (!state.client || !state.connected) return;
if (state.providersLoading) return; if (state.channelsLoading) return;
state.providersLoading = true; state.channelsLoading = true;
state.providersError = null; state.channelsError = null;
try { try {
const res = (await state.client.request("providers.status", { const res = (await state.client.request("channels.status", {
probe, probe,
timeoutMs: 8000, timeoutMs: 8000,
})) as ProvidersStatusSnapshot; })) as ChannelsStatusSnapshot;
state.providersSnapshot = res; state.channelsSnapshot = res;
state.providersLastSuccess = Date.now(); state.channelsLastSuccess = Date.now();
const providers = res.providers as Record<string, unknown>; const channels = res.channels as Record<string, unknown>;
const telegram = providers.telegram as { tokenSource?: string | null }; const telegram = channels.telegram as { tokenSource?: string | null };
const discord = providers.discord as { tokenSource?: string | null } | null; const discord = channels.discord as { tokenSource?: string | null } | null;
const slack = providers.slack as const slack = channels.slack as
| { botTokenSource?: string | null; appTokenSource?: string | null } | { botTokenSource?: string | null; appTokenSource?: string | null }
| null; | null;
state.telegramTokenLocked = telegram?.tokenSource === "env"; state.telegramTokenLocked = telegram?.tokenSource === "env";
@@ -71,9 +71,9 @@ export async function loadProviders(state: ConnectionsState, probe: boolean) {
state.slackTokenLocked = slack?.botTokenSource === "env"; state.slackTokenLocked = slack?.botTokenSource === "env";
state.slackAppTokenLocked = slack?.appTokenSource === "env"; state.slackAppTokenLocked = slack?.appTokenSource === "env";
} catch (err) { } catch (err) {
state.providersError = String(err); state.channelsError = String(err);
} finally { } finally {
state.providersLoading = false; state.channelsLoading = false;
} }
} }
@@ -119,7 +119,7 @@ export async function logoutWhatsApp(state: ConnectionsState) {
if (!state.client || !state.connected || state.whatsappBusy) return; if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true; state.whatsappBusy = true;
try { try {
await state.client.request("providers.logout", { provider: "whatsapp" }); await state.client.request("channels.logout", { channel: "whatsapp" });
state.whatsappLoginMessage = "Logged out."; state.whatsappLoginMessage = "Logged out.";
state.whatsappLoginQrDataUrl = null; state.whatsappLoginQrDataUrl = null;
state.whatsappLoginConnected = null; state.whatsappLoginConnected = null;

View File

@@ -73,7 +73,7 @@ export function buildCronPayload(form: CronFormState) {
kind: "agentTurn"; kind: "agentTurn";
message: string; message: string;
deliver?: boolean; deliver?: boolean;
provider?: channel?:
| "last" | "last"
| "whatsapp" | "whatsapp"
| "telegram" | "telegram"
@@ -85,7 +85,7 @@ export function buildCronPayload(form: CronFormState) {
timeoutSeconds?: number; timeoutSeconds?: number;
} = { kind: "agentTurn", message }; } = { kind: "agentTurn", message };
if (form.deliver) payload.deliver = true; if (form.deliver) payload.deliver = true;
if (form.provider) payload.provider = form.provider; if (form.channel) payload.channel = form.channel;
if (form.to.trim()) payload.to = form.to.trim(); if (form.to.trim()) payload.to = form.to.trim();
const timeoutSeconds = toNumber(form.timeoutSeconds, 0); const timeoutSeconds = toNumber(form.timeoutSeconds, 0);
if (timeoutSeconds > 0) payload.timeoutSeconds = timeoutSeconds; if (timeoutSeconds > 0) payload.timeoutSeconds = timeoutSeconds;

View File

@@ -161,7 +161,7 @@ export function subtitleForTab(tab: Tab) {
case "overview": case "overview":
return "Gateway status, entry points, and a fast health read."; return "Gateway status, entry points, and a fast health read.";
case "connections": case "connections":
return "Link providers and keep transport settings in sync."; return "Link channels and keep transport settings in sync.";
case "instances": case "instances":
return "Presence beacons from connected clients and nodes."; return "Presence beacons from connected clients and nodes.";
case "sessions": case "sessions":

View File

@@ -1,13 +1,13 @@
export type ProvidersStatusSnapshot = { export type ChannelsStatusSnapshot = {
ts: number; ts: number;
providerOrder: string[]; channelOrder: string[];
providerLabels: Record<string, string>; channelLabels: Record<string, string>;
providers: Record<string, unknown>; channels: Record<string, unknown>;
providerAccounts: Record<string, ProviderAccountSnapshot[]>; channelAccounts: Record<string, ChannelAccountSnapshot[]>;
providerDefaultAccountId: Record<string, string>; channelDefaultAccountId: Record<string, string>;
}; };
export type ProviderAccountSnapshot = { export type ChannelAccountSnapshot = {
accountId: string; accountId: string;
name?: string | null; name?: string | null;
enabled?: boolean | null; enabled?: boolean | null;

View File

@@ -170,7 +170,7 @@ export type CronFormState = {
payloadKind: "systemEvent" | "agentTurn"; payloadKind: "systemEvent" | "agentTurn";
payloadText: string; payloadText: string;
deliver: boolean; deliver: boolean;
provider: channel:
| "last" | "last"
| "whatsapp" | "whatsapp"
| "telegram" | "telegram"

View File

@@ -2,10 +2,10 @@ import { html, nothing } from "lit";
import { formatAgo } from "../format"; import { formatAgo } from "../format";
import type { import type {
ChannelAccountSnapshot,
ChannelsStatusSnapshot,
DiscordStatus, DiscordStatus,
IMessageStatus, IMessageStatus,
ProviderAccountSnapshot,
ProvidersStatusSnapshot,
SignalStatus, SignalStatus,
SlackStatus, SlackStatus,
TelegramStatus, TelegramStatus,
@@ -50,7 +50,7 @@ const slackActionOptions = [
export type ConnectionsProps = { export type ConnectionsProps = {
connected: boolean; connected: boolean;
loading: boolean; loading: boolean;
snapshot: ProvidersStatusSnapshot | null; snapshot: ChannelsStatusSnapshot | null;
lastError: string | null; lastError: string | null;
lastSuccessAt: number | null; lastSuccessAt: number | null;
whatsappMessage: string | null; whatsappMessage: string | null;
@@ -93,18 +93,18 @@ export type ConnectionsProps = {
}; };
export function renderConnections(props: ConnectionsProps) { export function renderConnections(props: ConnectionsProps) {
const providers = props.snapshot?.providers as Record<string, unknown> | null; const channels = props.snapshot?.channels as Record<string, unknown> | null;
const whatsapp = (providers?.whatsapp ?? undefined) as const whatsapp = (channels?.whatsapp ?? undefined) as
| WhatsAppStatus | WhatsAppStatus
| undefined; | undefined;
const telegram = (providers?.telegram ?? undefined) as const telegram = (channels?.telegram ?? undefined) as
| TelegramStatus | TelegramStatus
| undefined; | undefined;
const discord = (providers?.discord ?? null) as DiscordStatus | null; const discord = (channels?.discord ?? null) as DiscordStatus | null;
const slack = (providers?.slack ?? null) as SlackStatus | null; const slack = (channels?.slack ?? null) as SlackStatus | null;
const signal = (providers?.signal ?? null) as SignalStatus | null; const signal = (channels?.signal ?? null) as SignalStatus | null;
const imessage = (providers?.imessage ?? null) as IMessageStatus | null; const imessage = (channels?.imessage ?? null) as IMessageStatus | null;
const providerOrder: ProviderKey[] = [ const channelOrder: ChannelKey[] = [
"whatsapp", "whatsapp",
"telegram", "telegram",
"discord", "discord",
@@ -112,10 +112,10 @@ export function renderConnections(props: ConnectionsProps) {
"signal", "signal",
"imessage", "imessage",
]; ];
const orderedProviders = providerOrder const orderedChannels = channelOrder
.map((key, index) => ({ .map((key, index) => ({
key, key,
enabled: providerEnabled(key, props), enabled: channelEnabled(key, props),
order: index, order: index,
})) }))
.sort((a, b) => { .sort((a, b) => {
@@ -125,15 +125,15 @@ export function renderConnections(props: ConnectionsProps) {
return html` return html`
<section class="grid grid-cols-2"> <section class="grid grid-cols-2">
${orderedProviders.map((provider) => ${orderedChannels.map((channel) =>
renderProvider(provider.key, props, { renderChannel(channel.key, props, {
whatsapp, whatsapp,
telegram, telegram,
discord, discord,
slack, slack,
signal, signal,
imessage, imessage,
providerAccounts: props.snapshot?.providerAccounts ?? null, channelAccounts: props.snapshot?.channelAccounts ?? null,
}), }),
)} )}
</section> </section>
@@ -142,7 +142,7 @@ export function renderConnections(props: ConnectionsProps) {
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<div> <div>
<div class="card-title">Connection health</div> <div class="card-title">Connection health</div>
<div class="card-sub">Provider status snapshots from the gateway.</div> <div class="card-sub">Channel status snapshots from the gateway.</div>
</div> </div>
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div> <div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
</div> </div>
@@ -168,7 +168,7 @@ function formatDuration(ms?: number | null) {
return `${hr}h`; return `${hr}h`;
} }
type ProviderKey = type ChannelKey =
| "whatsapp" | "whatsapp"
| "telegram" | "telegram"
| "discord" | "discord"
@@ -176,16 +176,16 @@ type ProviderKey =
| "signal" | "signal"
| "imessage"; | "imessage";
function providerEnabled(key: ProviderKey, props: ConnectionsProps) { function channelEnabled(key: ChannelKey, props: ConnectionsProps) {
const snapshot = props.snapshot; const snapshot = props.snapshot;
const providers = snapshot?.providers as Record<string, unknown> | null; const channels = snapshot?.channels as Record<string, unknown> | null;
if (!snapshot || !providers) return false; if (!snapshot || !channels) return false;
const whatsapp = providers.whatsapp as WhatsAppStatus | undefined; const whatsapp = channels.whatsapp as WhatsAppStatus | undefined;
const telegram = providers.telegram as TelegramStatus | undefined; const telegram = channels.telegram as TelegramStatus | undefined;
const discord = (providers.discord ?? null) as DiscordStatus | null; const discord = (channels.discord ?? null) as DiscordStatus | null;
const slack = (providers.slack ?? null) as SlackStatus | null; const slack = (channels.slack ?? null) as SlackStatus | null;
const signal = (providers.signal ?? null) as SignalStatus | null; const signal = (channels.signal ?? null) as SignalStatus | null;
const imessage = (providers.imessage ?? null) as IMessageStatus | null; const imessage = (channels.imessage ?? null) as IMessageStatus | null;
switch (key) { switch (key) {
case "whatsapp": case "whatsapp":
return ( return (
@@ -208,24 +208,24 @@ function providerEnabled(key: ProviderKey, props: ConnectionsProps) {
} }
} }
function getProviderAccountCount( function getChannelAccountCount(
key: ProviderKey, key: ChannelKey,
providerAccounts?: Record<string, ProviderAccountSnapshot[]> | null, channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
): number { ): number {
return providerAccounts?.[key]?.length ?? 0; return channelAccounts?.[key]?.length ?? 0;
} }
function renderProviderAccountCount( function renderChannelAccountCount(
key: ProviderKey, key: ChannelKey,
providerAccounts?: Record<string, ProviderAccountSnapshot[]> | null, channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
) { ) {
const count = getProviderAccountCount(key, providerAccounts); const count = getChannelAccountCount(key, channelAccounts);
if (count < 2) return nothing; if (count < 2) return nothing;
return html`<div class="account-count">Accounts (${count})</div>`; return html`<div class="account-count">Accounts (${count})</div>`;
} }
function renderProvider( function renderChannel(
key: ProviderKey, key: ChannelKey,
props: ConnectionsProps, props: ConnectionsProps,
data: { data: {
whatsapp?: WhatsAppStatus; whatsapp?: WhatsAppStatus;
@@ -234,12 +234,12 @@ function renderProvider(
slack?: SlackStatus | null; slack?: SlackStatus | null;
signal?: SignalStatus | null; signal?: SignalStatus | null;
imessage?: IMessageStatus | null; imessage?: IMessageStatus | null;
providerAccounts?: Record<string, ProviderAccountSnapshot[]> | null; channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null;
}, },
) { ) {
const accountCountLabel = renderProviderAccountCount( const accountCountLabel = renderChannelAccountCount(
key, key,
data.providerAccounts, data.channelAccounts,
); );
switch (key) { switch (key) {
case "whatsapp": { case "whatsapp": {
@@ -345,10 +345,10 @@ function renderProvider(
} }
case "telegram": { case "telegram": {
const telegram = data.telegram; const telegram = data.telegram;
const telegramAccounts = data.providerAccounts?.telegram ?? []; const telegramAccounts = data.channelAccounts?.telegram ?? [];
const hasMultipleAccounts = telegramAccounts.length > 1; const hasMultipleAccounts = telegramAccounts.length > 1;
const renderAccountCard = (account: ProviderAccountSnapshot) => { const renderAccountCard = (account: ChannelAccountSnapshot) => {
const probe = account.probe as { bot?: { username?: string } } | undefined; const probe = account.probe as { bot?: { username?: string } } | undefined;
const botUsername = probe?.bot?.username; const botUsername = probe?.bot?.username;
const label = account.name || account.accountId; const label = account.name || account.accountId;

View File

@@ -168,9 +168,9 @@ export function renderCron(props: CronProps) {
rows="4" rows="4"
></textarea> ></textarea>
</label> </label>
${props.form.payloadKind === "agentTurn" ${props.form.payloadKind === "agentTurn"
? html` ? html`
<div class="form-grid" style="margin-top: 12px;"> <div class="form-grid" style="margin-top: 12px;">
<label class="field checkbox"> <label class="field checkbox">
<span>Deliver</span> <span>Deliver</span>
<input <input
@@ -181,17 +181,17 @@ export function renderCron(props: CronProps) {
deliver: (e.target as HTMLInputElement).checked, deliver: (e.target as HTMLInputElement).checked,
})} })}
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Provider</span> <span>Channel</span>
<select <select
.value=${props.form.provider} .value=${props.form.channel}
@change=${(e: Event) => @change=${(e: Event) =>
props.onFormChange({ props.onFormChange({
provider: (e.target as HTMLSelectElement).value as CronFormState["provider"], channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
})} })}
> >
<option value="last">Last</option> <option value="last">Last</option>
<option value="whatsapp">WhatsApp</option> <option value="whatsapp">WhatsApp</option>
<option value="telegram">Telegram</option> <option value="telegram">Telegram</option>
<option value="discord">Discord</option> <option value="discord">Discord</option>

View File

@@ -15,7 +15,7 @@ export type OverviewProps = {
sessionsCount: number | null; sessionsCount: number | null;
cronEnabled: boolean | null; cronEnabled: boolean | null;
cronNext: number | null; cronNext: number | null;
lastProvidersRefresh: number | null; lastChannelsRefresh: number | null;
onSettingsChange: (next: UiSettings) => void; onSettingsChange: (next: UiSettings) => void;
onPasswordChange: (next: string) => void; onPasswordChange: (next: string) => void;
onSessionKeyChange: (next: string) => void; onSessionKeyChange: (next: string) => void;
@@ -109,10 +109,10 @@ export function renderOverview(props: OverviewProps) {
<div class="stat-value">${tick}</div> <div class="stat-value">${tick}</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-label">Last Providers Refresh</div> <div class="stat-label">Last Channels Refresh</div>
<div class="stat-value"> <div class="stat-value">
${props.lastProvidersRefresh ${props.lastChannelsRefresh
? formatAgo(props.lastProvidersRefresh) ? formatAgo(props.lastChannelsRefresh)
: "n/a"} : "n/a"}
</div> </div>
</div> </div>

View File

@@ -47,7 +47,7 @@ export default defineConfig({
// Gateway server integration surfaces are intentionally validated via manual/e2e runs. // Gateway server integration surfaces are intentionally validated via manual/e2e runs.
"src/gateway/control-ui.ts", "src/gateway/control-ui.ts",
"src/gateway/server-bridge.ts", "src/gateway/server-bridge.ts",
"src/gateway/server-providers.ts", "src/gateway/server-channels.ts",
"src/gateway/server-methods/config.ts", "src/gateway/server-methods/config.ts",
"src/gateway/server-methods/send.ts", "src/gateway/server-methods/send.ts",
"src/gateway/server-methods/skills.ts", "src/gateway/server-methods/skills.ts",
@@ -62,13 +62,13 @@ export default defineConfig({
// Interactive UIs/flows are intentionally validated via manual/e2e runs. // Interactive UIs/flows are intentionally validated via manual/e2e runs.
"src/tui/**", "src/tui/**",
"src/wizard/**", "src/wizard/**",
// Provider surfaces are largely integration-tested (or manually validated). // Channel surfaces are largely integration-tested (or manually validated).
"src/discord/**", "src/discord/**",
"src/imessage/**", "src/imessage/**",
"src/signal/**", "src/signal/**",
"src/slack/**", "src/slack/**",
"src/browser/**", "src/browser/**",
"src/providers/web/**", "src/channels/web/**",
"src/telegram/index.ts", "src/telegram/index.ts",
"src/telegram/proxy.ts", "src/telegram/proxy.ts",
"src/telegram/webhook-set.ts", "src/telegram/webhook-set.ts",