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",
deliver: false,
to: nil,
provider: .last))
channel: .last))
case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
@@ -205,7 +205,7 @@ actor BridgeServer {
?? "node-\(nodeId)"
let thinking = link.thinking?.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(
message: message,
@@ -213,7 +213,7 @@ actor BridgeServer {
thinking: thinking,
deliver: link.deliver,
to: to,
provider: provider))
channel: channel))
default:
break

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ struct CronJobEditor: View {
static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
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."
static let mainPayloadNote =
"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 agentMessage: String = ""
@State var deliver: Bool = false
@State var provider: GatewayAgentProvider = .last
@State var channel: GatewayAgentChannel = .last
@State var to: String = ""
@State var thinking: String = ""
@State var timeoutSeconds: String = ""
@@ -323,7 +323,7 @@ struct CronJobEditor: View {
}
GridRow {
self.gridLabel("Deliver")
Toggle("Deliver result to a provider", isOn: self.$deliver)
Toggle("Deliver result to a channel", isOn: self.$deliver)
.toggleStyle(.switch)
}
}
@@ -331,15 +331,15 @@ struct CronJobEditor: View {
if self.deliver {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Provider")
Picker("", selection: self.$provider) {
Text("last").tag(GatewayAgentProvider.last)
Text("whatsapp").tag(GatewayAgentProvider.whatsapp)
Text("telegram").tag(GatewayAgentProvider.telegram)
Text("discord").tag(GatewayAgentProvider.discord)
Text("slack").tag(GatewayAgentProvider.slack)
Text("signal").tag(GatewayAgentProvider.signal)
Text("imessage").tag(GatewayAgentProvider.imessage)
self.gridLabel("Channel")
Picker("", selection: self.$channel) {
Text("last").tag(GatewayAgentChannel.last)
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
Text("telegram").tag(GatewayAgentChannel.telegram)
Text("discord").tag(GatewayAgentChannel.discord)
Text("slack").tag(GatewayAgentChannel.slack)
Text("signal").tag(GatewayAgentChannel.signal)
Text("imessage").tag(GatewayAgentChannel.imessage)
}
.labelsHidden()
.pickerStyle(.segmented)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -694,7 +694,7 @@ extension OnboardingView {
systemImage: "bubble.left.and.bubble.right")
self.featureActionRow(
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")
{
self.openSettings(tab: .connections)

View File

@@ -37,7 +37,7 @@ enum VoiceWakeForwarder {
var thinking: String = "low"
var deliver: Bool = true
var to: String?
var provider: GatewayAgentProvider = .last
var channel: GatewayAgentChannel = .last
}
@discardableResult
@@ -46,14 +46,14 @@ enum VoiceWakeForwarder {
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
{
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(
message: payload,
sessionKey: options.sessionKey,
thinking: options.thinking,
deliver: deliver,
to: options.to,
provider: options.provider))
channel: options.channel))
if result.ok {
self.logger.info("voice wake forward ok")

View File

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

View File

@@ -2,17 +2,17 @@ import Foundation
import Testing
@testable import Clawdbot
@Suite struct HealthDecodeTests {
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}]}}
@Suite struct HealthDecodeTests {
private let sampleJSON: String = // minimal but complete payload
"""
{"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 {
let data = Data(sampleJSON.utf8)
let snap = decodeHealthSnapshot(from: data)
#expect(snap?.providers["whatsapp"]?.linked == true)
#expect(snap?.channels["whatsapp"]?.linked == true)
#expect(snap?.sessions.count == 1)
}
@@ -20,7 +20,7 @@ import Testing
let noisy = "debug: something logged\n" + self.sampleJSON + "\ntrailer"
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 {

View File

@@ -3,12 +3,12 @@ import Testing
@testable import Clawdbot
@Suite struct HealthStoreStateTests {
@Test @MainActor func linkedProviderProbeFailureDegradesState() async throws {
@Test @MainActor func linkedChannelProbeFailureDegradesState() async throws {
let snap = HealthSnapshot(
ok: true,
ts: 0,
durationMs: 1,
providers: [
channels: [
"whatsapp": .init(
configured: true,
linked: true,
@@ -22,8 +22,8 @@ import Testing
webhook: nil),
lastProbeAt: 0),
],
providerOrder: ["whatsapp"],
providerLabels: ["whatsapp": "WhatsApp"],
channelOrder: ["whatsapp"],
channelLabels: ["whatsapp": "WhatsApp"],
heartbeatSeconds: 60,
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: []))
@@ -34,7 +34,7 @@ import Testing
case let .degraded(message):
#expect(!message.isEmpty)
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"))

View File

@@ -36,7 +36,7 @@ Short, exact flow of one agent run.
- `assistant`: streamed deltas 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.
- A chat `final` is emitted on **lifecycle end/error**.

View File

@@ -6,7 +6,7 @@ read_when:
---
# 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).
## Quick rules

View File

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

View File

@@ -22,15 +22,15 @@ echo "Creating Docker network..."
docker network create "$NET_NAME" >/dev/null
echo "Starting gateway container..."
docker run --rm -d \
--name "$GW_NAME" \
--network "$NET_NAME" \
-e "CLAWDBOT_GATEWAY_TOKEN=$TOKEN" \
-e "CLAWDBOT_SKIP_PROVIDERS=1" \
-e "CLAWDBOT_SKIP_GMAIL_WATCHER=1" \
-e "CLAWDBOT_SKIP_CRON=1" \
-e "CLAWDBOT_SKIP_CANVAS_HOST=1" \
"$IMAGE_NAME" \
docker run --rm -d \
--name "$GW_NAME" \
--network "$NET_NAME" \
-e "CLAWDBOT_GATEWAY_TOKEN=$TOKEN" \
-e "CLAWDBOT_SKIP_CHANNELS=1" \
-e "CLAWDBOT_SKIP_GMAIL_WATCHER=1" \
-e "CLAWDBOT_SKIP_CRON=1" \
-e "CLAWDBOT_SKIP_CANVAS_HOST=1" \
"$IMAGE_NAME" \
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..."

View File

@@ -445,11 +445,11 @@ function buildCollectPrompt(items: FollowupRun[], summary?: string): string {
/**
* 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.
* 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>();
let hasUnkeyed = false;
@@ -499,33 +499,33 @@ export function scheduleFollowupDrain(
if (forceIndividualCollect) {
const next = queue.items.shift();
if (!next) break;
await runFollowup(next);
continue;
}
await runFollowup(next);
continue;
}
// Check if messages span multiple providers.
// If so, process individually to preserve per-message routing.
const isCrossProvider = hasCrossProviderItems(queue.items);
// Check if messages span multiple channels.
// If so, process individually to preserve per-message routing.
const isCrossChannel = hasCrossChannelItems(queue.items);
if (isCrossProvider) {
forceIndividualCollect = true;
// Process one at a time to preserve per-message routing info.
const next = queue.items.shift();
if (!next) break;
await runFollowup(next);
continue;
}
if (isCrossChannel) {
forceIndividualCollect = true;
// Process one at a time to preserve per-message routing info.
const next = queue.items.shift();
if (!next) break;
await runFollowup(next);
continue;
}
// Same-provider messages can be safely collected.
const items = queue.items.splice(0, queue.items.length);
const summary = buildSummaryPrompt(queue);
const run = items.at(-1)?.run ?? queue.lastRun;
if (!run) break;
// Same-channel messages can be safely collected.
const items = queue.items.splice(0, queue.items.length);
const summary = buildSummaryPrompt(queue);
const run = items.at(-1)?.run ?? queue.lastRun;
if (!run) break;
// Preserve originating channel from items when collecting same-provider.
const originatingChannel = items.find(
(i) => i.originatingChannel,
)?.originatingChannel;
// Preserve originating channel from items when collecting same-channel.
const originatingChannel = items.find(
(i) => i.originatingChannel,
)?.originatingChannel;
const originatingTo = items.find(
(i) => i.originatingTo,
)?.originatingTo;

View File

@@ -108,7 +108,7 @@ describe("gateway SIGTERM", () => {
...process.env,
CLAWDBOT_STATE_DIR: stateDir,
CLAWDBOT_CONFIG_PATH: configPath,
CLAWDBOT_SKIP_PROVIDERS: "1",
CLAWDBOT_SKIP_CHANNELS: "1",
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
CLAWDBOT_SKIP_CANVAS_HOST: "1",
// 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", () => {
it("writes gateway token auth into config and gateway enforces it", async () => {
const prev = {
home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
};
const prev = {
home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
};
process.env.CLAWDBOT_SKIP_PROVIDERS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
const tempHome = await fs.mkdtemp(
@@ -186,7 +186,7 @@ describe("onboard (non-interactive): gateway auth", () => {
process.env.HOME = prev.home;
process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
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_CRON = prev.skipCron;
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.
return;
}
const prev = {
home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
};
const prev = {
home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
};
process.env.CLAWDBOT_SKIP_PROVIDERS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
const tempHome = await fs.mkdtemp(
@@ -215,7 +215,7 @@ describe("onboard (non-interactive): lan bind auto-token", () => {
process.env.HOME = prev.home;
process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
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_CRON = prev.skipCron;
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", () => {
it("writes gateway.remote url/token and callGateway uses them", async () => {
const prev = {
home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
password: process.env.CLAWDBOT_GATEWAY_PASSWORD,
};
const prev = {
home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
password: process.env.CLAWDBOT_GATEWAY_PASSWORD,
};
process.env.CLAWDBOT_SKIP_PROVIDERS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
@@ -104,16 +104,16 @@ describe("onboard (non-interactive): remote gateway config", () => {
expect(health?.ok).toBe(true);
} finally {
await server.close({ reason: "non-interactive remote test complete" });
await fs.rm(tempHome, { recursive: true, force: true });
process.env.HOME = prev.home;
process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_SKIP_PROVIDERS = prev.skipProviders;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
process.env.CLAWDBOT_GATEWAY_PASSWORD = prev.password;
}
await fs.rm(tempHome, { recursive: true, force: true });
process.env.HOME = prev.home;
process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
process.env.CLAWDBOT_GATEWAY_PASSWORD = prev.password;
}
}, 60_000);
});

View File

@@ -285,7 +285,7 @@ async function auditGatewayRuntime(
issues.push({
code: SERVICE_AUDIT_CODES.gatewayRuntimeBun,
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,
level: "recommended",
});

View File

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

View File

@@ -352,7 +352,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
const previous = {
configPath: process.env.CLAWDBOT_CONFIG_PATH,
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,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
@@ -363,7 +363,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
let tempAgentDir: 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_CRON = "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_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_CRON = previous.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = previous.skipCanvas;
@@ -895,13 +895,13 @@ describeLive("gateway live (dev agent, profile keys)", () => {
const previous = {
configPath: process.env.CLAWDBOT_CONFIG_PATH,
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,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
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_CRON = "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_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_CRON = previous.skipCron;
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", () => {
it("runs a Read tool call end-to-end via gateway agent loop", async () => {
const prev = {
home: process.env.HOME,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
};
const prev = {
home: process.env.HOME,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
};
const originalFetch = globalThis.fetch;
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.
(globalThis as unknown as { fetch: unknown }).fetch = fetchImpl;
const tempHome = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-gw-mock-home-"),
);
process.env.HOME = tempHome;
process.env.CLAWDBOT_SKIP_PROVIDERS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
const tempHome = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-gw-mock-home-"),
);
process.env.HOME = tempHome;
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
const token = `test-${randomUUID()}`;
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 fs.rm(tempHome, { recursive: true, force: true });
(globalThis as unknown as { fetch: unknown }).fetch = originalFetch;
process.env.HOME = prev.home;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
process.env.CLAWDBOT_SKIP_PROVIDERS = prev.skipProviders;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
}
process.env.HOME = prev.home;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
}
}, 30_000);
});

View File

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

View File

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

View File

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

View File

@@ -430,9 +430,9 @@ const SUBSYSTEM_COLOR_OVERRIDES: Record<
> = {
"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 PROVIDER_SUBSYSTEM_PREFIXES = new Set<string>(CHAT_CHANNEL_ORDER);
const CHANNEL_SUBSYSTEM_PREFIXES = new Set<string>(CHAT_CHANNEL_ORDER);
function pickSubsystemColor(
color: ChalkInstance,
@@ -461,7 +461,7 @@ function formatSubsystemForConsole(subsystem: string): string {
parts.shift();
}
if (parts.length === 0) return original;
if (PROVIDER_SUBSYSTEM_PREFIXES.has(parts[0])) {
if (CHANNEL_SUBSYSTEM_PREFIXES.has(parts[0])) {
return parts[0];
}
if (parts.length > SUBSYSTEM_MAX_SEGMENTS) {

View File

@@ -249,14 +249,14 @@ export async function runOnboardingWizard(
`Tailscale exposure: ${formatTailscale(
quickstartGateway.tailscaleMode,
)}`,
"Direct to chat providers.",
"Direct to chat channels.",
]
: [
`Gateway port: ${DEFAULT_GATEWAY_PORT}`,
"Gateway bind: Loopback (127.0.0.1)",
"Gateway auth: Token (default)",
"Tailscale exposure: Off",
"Direct to chat providers.",
"Direct to chat channels.",
];
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_GATEWAY_TOKEN: "",
CLAWDBOT_GATEWAY_PASSWORD: "",
CLAWDBOT_SKIP_PROVIDERS: "1",
CLAWDBOT_SKIP_CHANNELS: "1",
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
CLAWDBOT_SKIP_CANVAS_HOST: "1",
CLAWDBOT_ENABLE_BRIDGE_IN_TESTS: "1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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