diff --git a/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift b/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift index 1ece2507a..60d01459a 100644 --- a/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift +++ b/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift @@ -187,7 +187,7 @@ actor BridgeServer { thinking: "low", deliver: false, to: nil, - channel: .last)) + provider: .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 channel = GatewayAgentChannel(raw: link.channel) + let provider = GatewayAgentProvider(raw: link.channel) _ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( message: message, @@ -213,7 +213,7 @@ actor BridgeServer { thinking: thinking, deliver: link.deliver, to: to, - channel: channel)) + provider: provider)) default: break diff --git a/apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift b/apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift index 7fdff8afb..bb92cdf78 100644 --- a/apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift +++ b/apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift @@ -79,14 +79,14 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler { GatewayProcessManager.shared.setActive(true) } - let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( - message: text, - sessionKey: self.sessionKey, - thinking: "low", - deliver: false, - to: nil, - channel: .last, - idempotencyKey: actionId)) + let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( + message: text, + sessionKey: self.sessionKey, + thinking: "low", + deliver: false, + to: nil, + provider: .last, + idempotencyKey: actionId)) await MainActor.run { guard let webView else { return } diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift index c8ef08668..ec07cc5e4 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift @@ -33,13 +33,13 @@ extension CronJobEditor { case let .systemEvent(text): self.payloadKind = .systemEvent self.systemEventText = text - case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): + case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver): self.payloadKind = .agentTurn self.agentMessage = message self.thinking = thinking ?? "" self.timeoutSeconds = timeoutSeconds.map(String.init) ?? "" self.deliver = deliver ?? false - self.channel = GatewayAgentChannel(raw: channel) + self.provider = GatewayAgentProvider(raw: provider) self.to = to ?? "" self.bestEffortDeliver = bestEffortDeliver ?? false } @@ -166,7 +166,7 @@ extension CronJobEditor { if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n } payload["deliver"] = self.deliver if self.deliver { - payload["channel"] = self.channel.rawValue + payload["provider"] = self.provider.rawValue let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines) if !to.isEmpty { payload["to"] = to } payload["bestEffortDeliver"] = self.bestEffortDeliver diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift b/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift index 803964bcc..75b5ed6b6 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift @@ -13,7 +13,7 @@ extension CronJobEditor { self.payloadKind = .agentTurn self.agentMessage = "Run diagnostic" self.deliver = true - self.channel = .last + self.provider = .last self.to = "+15551230000" self.thinking = "low" self.timeoutSeconds = "90" diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor.swift b/apps/macos/Sources/Clawdbot/CronJobEditor.swift index 093978ebb..93d2615bf 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor.swift @@ -17,7 +17,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 surface, " + "Isolated jobs always run an agent turn. The result can be delivered to a provider, " + "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." @@ -42,7 +42,7 @@ struct CronJobEditor: View { @State var systemEventText: String = "" @State var agentMessage: String = "" @State var deliver: Bool = false - @State var channel: GatewayAgentChannel = .last + @State var provider: GatewayAgentProvider = .last @State var to: String = "" @State var thinking: String = "" @State var timeoutSeconds: String = "" @@ -309,7 +309,7 @@ struct CronJobEditor: View { } GridRow { self.gridLabel("Deliver") - Toggle("Deliver result to a surface", isOn: self.$deliver) + Toggle("Deliver result to a provider", isOn: self.$deliver) .toggleStyle(.switch) } } @@ -317,15 +317,15 @@ struct CronJobEditor: View { if self.deliver { Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { GridRow { - 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) + 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) } .labelsHidden() .pickerStyle(.segmented) diff --git a/apps/macos/Sources/Clawdbot/CronModels.swift b/apps/macos/Sources/Clawdbot/CronModels.swift index 78c009576..388ef8afb 100644 --- a/apps/macos/Sources/Clawdbot/CronModels.swift +++ b/apps/macos/Sources/Clawdbot/CronModels.swift @@ -74,12 +74,12 @@ enum CronPayload: Codable, Equatable { thinking: String?, timeoutSeconds: Int?, deliver: Bool?, - channel: String?, + provider: String?, to: String?, bestEffortDeliver: Bool?) enum CodingKeys: String, CodingKey { - case kind, text, message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver + case kind, text, message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver } var kind: String { @@ -101,7 +101,7 @@ enum CronPayload: Codable, Equatable { 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), + provider: container.decodeIfPresent(String.self, forKey: .provider), to: container.decodeIfPresent(String.self, forKey: .to), bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)) default: @@ -118,12 +118,12 @@ enum CronPayload: Codable, Equatable { switch self { case let .systemEvent(text): try container.encode(text, forKey: .text) - case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): + 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(channel, forKey: .channel) + try container.encodeIfPresent(provider, forKey: .provider) try container.encodeIfPresent(to, forKey: .to) try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver) } diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift b/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift index b815bb234..fc5ceb51f 100644 --- a/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift +++ b/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift @@ -206,7 +206,7 @@ extension CronSettings { Text(text) .font(.callout) .textSelection(.enabled) - case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, _): + case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, _): VStack(alignment: .leading, spacing: 4) { Text(message) .font(.callout) @@ -216,7 +216,7 @@ extension CronSettings { if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) } if deliver ?? false { StatusPill(text: "deliver", tint: .secondary) - if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) } + if let provider, !provider.isEmpty { StatusPill(text: provider, tint: .secondary) } if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) } } } diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift b/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift index bf6898016..788ec9fdb 100644 --- a/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift +++ b/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift @@ -20,7 +20,7 @@ struct CronSettings_Previews: PreviewProvider { thinking: "low", timeoutSeconds: 600, deliver: true, - channel: "last", + provider: "last", to: nil, bestEffortDeliver: true), isolation: CronIsolation(postToMainPrefix: "Cron"), @@ -72,7 +72,7 @@ extension CronSettings { thinking: "low", timeoutSeconds: 120, deliver: true, - channel: "whatsapp", + provider: "whatsapp", to: "+15551234567", bestEffortDeliver: true), isolation: CronIsolation(postToMainPrefix: "[cron] "), diff --git a/apps/macos/Sources/Clawdbot/DeepLinks.swift b/apps/macos/Sources/Clawdbot/DeepLinks.swift index 0ffa87908..b1960b239 100644 --- a/apps/macos/Sources/Clawdbot/DeepLinks.swift +++ b/apps/macos/Sources/Clawdbot/DeepLinks.swift @@ -59,7 +59,7 @@ final class DeepLinkHandler { } do { - let channel = GatewayAgentChannel(raw: link.channel) + let provider = GatewayAgentProvider(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: channel.shouldDeliver(link.deliver), + deliver: provider.shouldDeliver(link.deliver), to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, - channel: channel, + provider: provider, timeoutSeconds: link.timeoutSeconds, idempotencyKey: UUID().uuidString) diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index d176be624..fb1f0a7d6 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -5,7 +5,7 @@ import OSLog private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection") -enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { +enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable { case last case whatsapp case telegram @@ -17,7 +17,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { init(raw: String?) { let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - self = GatewayAgentChannel(rawValue: normalized) ?? .last + self = GatewayAgentProvider(rawValue: normalized) ?? .last } var isDeliverable: Bool { self != .webchat } @@ -31,7 +31,7 @@ struct GatewayAgentInvocation: Sendable { var thinking: String? var deliver: Bool = false var to: String? - var channel: GatewayAgentChannel = .last + var provider: GatewayAgentProvider = .last var timeoutSeconds: Int? var idempotencyKey: String = UUID().uuidString } @@ -368,7 +368,7 @@ extension GatewayConnection { "thinking": AnyCodable(invocation.thinking ?? "default"), "deliver": AnyCodable(invocation.deliver), "to": AnyCodable(invocation.to ?? ""), - "channel": AnyCodable(invocation.channel.rawValue), + "provider": AnyCodable(invocation.provider.rawValue), "idempotencyKey": AnyCodable(invocation.idempotencyKey), ] if let timeout = invocation.timeoutSeconds { @@ -389,7 +389,7 @@ extension GatewayConnection { sessionKey: String, deliver: Bool, to: String?, - channel: GatewayAgentChannel = .last, + provider: GatewayAgentProvider = .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, - channel: channel, + provider: provider, timeoutSeconds: timeoutSeconds, idempotencyKey: idempotencyKey)) } diff --git a/apps/macos/Sources/Clawdbot/SessionData.swift b/apps/macos/Sources/Clawdbot/SessionData.swift index b1d8b7886..7ce1dc8fc 100644 --- a/apps/macos/Sources/Clawdbot/SessionData.swift +++ b/apps/macos/Sources/Clawdbot/SessionData.swift @@ -9,7 +9,7 @@ struct GatewaySessionDefaultsRecord: Codable { struct GatewaySessionEntryRecord: Codable { let key: String let displayName: String? - let surface: String? + let provider: String? let subject: String? let room: String? let space: String? @@ -71,7 +71,7 @@ struct SessionRow: Identifiable { let key: String let kind: SessionKind let displayName: String? - let surface: String? + let provider: String? let subject: String? let room: String? let space: String? @@ -141,7 +141,7 @@ extension SessionRow { key: "user@example.com", kind: .direct, displayName: nil, - surface: nil, + provider: nil, subject: nil, room: nil, space: nil, @@ -158,7 +158,7 @@ extension SessionRow { key: "discord:channel:release-squad", kind: .group, displayName: "discord:#release-squad", - surface: "discord", + provider: "discord", subject: nil, room: "#release-squad", space: nil, @@ -175,7 +175,7 @@ extension SessionRow { key: "global", kind: .global, displayName: nil, - surface: nil, + provider: nil, subject: nil, room: nil, space: nil, @@ -298,7 +298,7 @@ enum SessionLoader { key: entry.key, kind: SessionKind.from(key: entry.key), displayName: entry.displayName, - surface: entry.surface, + provider: entry.provider, subject: entry.subject, room: entry.room, space: entry.space, diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift b/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift index 3fd9f827b..a30d389f3 100644 --- a/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift +++ b/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift @@ -37,7 +37,7 @@ enum VoiceWakeForwarder { var thinking: String = "low" var deliver: Bool = true var to: String? - var channel: GatewayAgentChannel = .last + var provider: GatewayAgentProvider = .last } @discardableResult @@ -46,14 +46,14 @@ enum VoiceWakeForwarder { options: ForwardOptions = ForwardOptions()) async -> Result { let payload = Self.prefixedTranscript(transcript) - let deliver = options.channel.shouldDeliver(options.deliver) + let deliver = options.provider.shouldDeliver(options.deliver) let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( message: payload, sessionKey: options.sessionKey, thinking: options.thinking, deliver: deliver, to: options.to, - channel: options.channel)) + provider: options.provider)) if result.ok { self.logger.info("voice wake forward ok") diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts new file mode 100644 index 000000000..4b3ea01d6 --- /dev/null +++ b/src/agents/agent-scope.ts @@ -0,0 +1,55 @@ +import os from "node:os"; +import path from "node:path"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import { + DEFAULT_AGENT_ID, + normalizeAgentId, + parseAgentSessionKey, +} from "../routing/session-key.js"; +import { resolveUserPath } from "../utils.js"; +import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js"; + +export function resolveAgentIdFromSessionKey( + sessionKey?: string | null, +): string { + const parsed = parseAgentSessionKey(sessionKey); + return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID); +} + +export function resolveAgentConfig( + cfg: ClawdbotConfig, + agentId: string, +): { workspace?: string; agentDir?: string } | undefined { + const id = normalizeAgentId(agentId); + const agents = cfg.routing?.agents; + if (!agents || typeof agents !== "object") return undefined; + const entry = agents[id]; + if (!entry || typeof entry !== "object") return undefined; + return { + workspace: + typeof entry.workspace === "string" ? entry.workspace : undefined, + agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined, + }; +} + +export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) { + const id = normalizeAgentId(agentId); + const configured = resolveAgentConfig(cfg, id)?.workspace?.trim(); + if (configured) return resolveUserPath(configured); + if (id === DEFAULT_AGENT_ID) { + const legacy = cfg.agent?.workspace?.trim(); + if (legacy) return resolveUserPath(legacy); + return DEFAULT_AGENT_WORKSPACE_DIR; + } + return path.join(os.homedir(), `clawd-${id}`); +} + +export function resolveAgentDir(cfg: ClawdbotConfig, agentId: string) { + const id = normalizeAgentId(agentId); + const configured = resolveAgentConfig(cfg, id)?.agentDir?.trim(); + if (configured) return resolveUserPath(configured); + const root = resolveStateDir(process.env, os.homedir); + return path.join(root, "agents", id, "agent"); +} diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 1d96c94ac..8722de5e5 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -49,14 +49,14 @@ export type AuthProfileStore = { type LegacyAuthStore = Record; -function resolveAuthStorePath(): string { - const agentDir = resolveClawdbotAgentDir(); - return path.join(agentDir, AUTH_PROFILE_FILENAME); +function resolveAuthStorePath(agentDir?: string): string { + const resolved = resolveUserPath(agentDir ?? resolveClawdbotAgentDir()); + return path.join(resolved, AUTH_PROFILE_FILENAME); } -function resolveLegacyAuthStorePath(): string { - const agentDir = resolveClawdbotAgentDir(); - return path.join(agentDir, LEGACY_AUTH_FILENAME); +function resolveLegacyAuthStorePath(agentDir?: string): string { + const resolved = resolveUserPath(agentDir ?? resolveClawdbotAgentDir()); + return path.join(resolved, LEGACY_AUTH_FILENAME); } function loadJsonFile(pathname: string): unknown { @@ -104,8 +104,9 @@ function buildOAuthApiKey( async function refreshOAuthTokenWithLock(params: { profileId: string; provider: OAuthProvider; + agentDir?: string; }): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { - const authPath = resolveAuthStorePath(); + const authPath = resolveAuthStorePath(params.agentDir); ensureAuthStoreFile(authPath); let release: (() => Promise) | undefined; @@ -121,7 +122,7 @@ async function refreshOAuthTokenWithLock(params: { stale: 30_000, }); - const store = ensureAuthProfileStore(); + const store = ensureAuthProfileStore(params.agentDir); const cred = store.profiles[params.profileId]; if (!cred || cred.type !== "oauth") return null; @@ -142,7 +143,7 @@ async function refreshOAuthTokenWithLock(params: { ...result.newCredentials, type: "oauth", }; - saveAuthProfileStore(store); + saveAuthProfileStore(store, params.agentDir); return result; } finally { if (release) { @@ -261,13 +262,13 @@ export function loadAuthProfileStore(): AuthProfileStore { return { version: AUTH_STORE_VERSION, profiles: {} }; } -export function ensureAuthProfileStore(): AuthProfileStore { - const authPath = resolveAuthStorePath(); +export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore { + const authPath = resolveAuthStorePath(agentDir); const raw = loadJsonFile(authPath); const asStore = coerceAuthStore(raw); if (asStore) return asStore; - const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath()); + const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir)); const legacy = coerceLegacyStore(legacyRaw); const store: AuthProfileStore = { version: AUTH_STORE_VERSION, @@ -307,8 +308,11 @@ export function ensureAuthProfileStore(): AuthProfileStore { return store; } -export function saveAuthProfileStore(store: AuthProfileStore): void { - const authPath = resolveAuthStorePath(); +export function saveAuthProfileStore( + store: AuthProfileStore, + agentDir?: string, +): void { + const authPath = resolveAuthStorePath(agentDir); const payload = { version: AUTH_STORE_VERSION, profiles: store.profiles, @@ -321,10 +325,11 @@ export function saveAuthProfileStore(store: AuthProfileStore): void { export function upsertAuthProfile(params: { profileId: string; credential: AuthProfileCredential; + agentDir?: string; }): void { - const store = ensureAuthProfileStore(); + const store = ensureAuthProfileStore(params.agentDir); store.profiles[params.profileId] = params.credential; - saveAuthProfileStore(store); + saveAuthProfileStore(store, params.agentDir); } export function listProfilesForProvider( @@ -354,8 +359,9 @@ export function isProfileInCooldown( export function markAuthProfileUsed(params: { store: AuthProfileStore; profileId: string; + agentDir?: string; }): void { - const { store, profileId } = params; + const { store, profileId, agentDir } = params; if (!store.profiles[profileId]) return; store.usageStats = store.usageStats ?? {}; @@ -365,7 +371,7 @@ export function markAuthProfileUsed(params: { errorCount: 0, cooldownUntil: undefined, }; - saveAuthProfileStore(store); + saveAuthProfileStore(store, agentDir); } export function calculateAuthProfileCooldownMs(errorCount: number): number { @@ -383,8 +389,9 @@ export function calculateAuthProfileCooldownMs(errorCount: number): number { export function markAuthProfileCooldown(params: { store: AuthProfileStore; profileId: string; + agentDir?: string; }): void { - const { store, profileId } = params; + const { store, profileId, agentDir } = params; if (!store.profiles[profileId]) return; store.usageStats = store.usageStats ?? {}; @@ -399,7 +406,7 @@ export function markAuthProfileCooldown(params: { errorCount, cooldownUntil: Date.now() + backoffMs, }; - saveAuthProfileStore(store); + saveAuthProfileStore(store, agentDir); } /** @@ -408,8 +415,9 @@ export function markAuthProfileCooldown(params: { export function clearAuthProfileCooldown(params: { store: AuthProfileStore; profileId: string; + agentDir?: string; }): void { - const { store, profileId } = params; + const { store, profileId, agentDir } = params; if (!store.usageStats?.[profileId]) return; store.usageStats[profileId] = { @@ -417,7 +425,7 @@ export function clearAuthProfileCooldown(params: { errorCount: 0, cooldownUntil: undefined, }; - saveAuthProfileStore(store); + saveAuthProfileStore(store, agentDir); } export function resolveAuthProfileOrder(params: { @@ -527,6 +535,7 @@ export async function resolveApiKeyForProfile(params: { cfg?: ClawdbotConfig; store: AuthProfileStore; profileId: string; + agentDir?: string; }): Promise<{ apiKey: string; provider: string; email?: string } | null> { const { cfg, store, profileId } = params; const cred = store.profiles[profileId]; @@ -550,6 +559,7 @@ export async function resolveApiKeyForProfile(params: { const result = await refreshOAuthTokenWithLock({ profileId, provider: cred.provider, + agentDir: params.agentDir, }); if (!result) return null; return { @@ -558,7 +568,7 @@ export async function resolveApiKeyForProfile(params: { email: cred.email, }; } catch (error) { - const refreshedStore = ensureAuthProfileStore(); + const refreshedStore = ensureAuthProfileStore(params.agentDir); const refreshed = refreshedStore.profiles[profileId]; if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) { return { @@ -579,12 +589,13 @@ export function markAuthProfileGood(params: { store: AuthProfileStore; provider: string; profileId: string; + agentDir?: string; }): void { - const { store, provider, profileId } = params; + const { store, provider, profileId, agentDir } = params; const profile = store.profiles[profileId]; if (!profile || profile.provider !== provider) return; store.lastGood = { ...store.lastGood, [provider]: profileId }; - saveAuthProfileStore(store); + saveAuthProfileStore(store, agentDir); } export function resolveAuthStorePathForDisplay(): string { diff --git a/src/agents/clawdbot-tools.sessions.test.ts b/src/agents/clawdbot-tools.sessions.test.ts index 10b6b5b34..c7df1cf79 100644 --- a/src/agents/clawdbot-tools.sessions.test.ts +++ b/src/agents/clawdbot-tools.sessions.test.ts @@ -36,14 +36,14 @@ describe("sessions tools", () => { kind: "direct", sessionId: "s-main", updatedAt: 10, - lastChannel: "whatsapp", + lastProvider: "whatsapp", }, { key: "discord:group:dev", kind: "group", sessionId: "s-group", updatedAt: 11, - surface: "discord", + provider: "discord", displayName: "discord:g-dev", }, { @@ -196,7 +196,7 @@ describe("sessions tools", () => { const tool = createClawdbotTools({ agentSessionKey: requesterKey, - agentSurface: "discord", + agentProvider: "discord", }).find((candidate) => candidate.name === "sessions_send"); expect(tool).toBeDefined(); if (!tool) throw new Error("missing sessions_send tool"); @@ -340,7 +340,7 @@ describe("sessions tools", () => { const tool = createClawdbotTools({ agentSessionKey: requesterKey, - agentSurface: "discord", + agentProvider: "discord", }).find((candidate) => candidate.name === "sessions_send"); expect(tool).toBeDefined(); if (!tool) throw new Error("missing sessions_send tool"); diff --git a/src/agents/clawdbot-tools.subagents.test.ts b/src/agents/clawdbot-tools.subagents.test.ts index 4751abee8..0d1d61f0a 100644 --- a/src/agents/clawdbot-tools.subagents.test.ts +++ b/src/agents/clawdbot-tools.subagents.test.ts @@ -22,7 +22,7 @@ vi.mock("../config/config.js", async (importOriginal) => { import { createClawdbotTools } from "./clawdbot-tools.js"; describe("subagents", () => { - it("sessions_spawn announces back to the requester group surface", async () => { + it("sessions_spawn announces back to the requester group provider", async () => { callGatewayMock.mockReset(); const calls: Array<{ method?: string; params?: unknown }> = []; let agentCallCount = 0; @@ -83,7 +83,7 @@ describe("subagents", () => { const tool = createClawdbotTools({ agentSessionKey: "discord:group:req", - agentSurface: "discord", + agentProvider: "discord", }).find((candidate) => candidate.name === "sessions_spawn"); if (!tool) throw new Error("missing sessions_spawn tool"); @@ -103,14 +103,14 @@ describe("subagents", () => { | undefined; expect(first?.lane).toBe("subagent"); expect(first?.deliver).toBe(false); - expect(first?.sessionKey?.startsWith("subagent:")).toBe(true); + expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); expect(sendParams).toMatchObject({ provider: "discord", to: "channel:req", message: "announce now", }); - expect(deletedKey?.startsWith("subagent:")).toBe(true); + expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); }); it("sessions_spawn resolves main announce target from sessions.list", async () => { @@ -129,7 +129,7 @@ describe("subagents", () => { sessions: [ { key: "main", - lastChannel: "whatsapp", + lastProvider: "whatsapp", lastTo: "+123", }, ], @@ -182,7 +182,7 @@ describe("subagents", () => { const tool = createClawdbotTools({ agentSessionKey: "main", - agentSurface: "whatsapp", + agentProvider: "whatsapp", }).find((candidate) => candidate.name === "sessions_spawn"); if (!tool) throw new Error("missing sessions_spawn tool"); diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index b4400eba8..447a098b0 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -16,11 +16,15 @@ import { createSlackTool } from "./tools/slack-tool.js"; export function createClawdbotTools(options?: { browserControlUrl?: string; agentSessionKey?: string; - agentSurface?: string; + agentProvider?: string; + agentDir?: string; sandboxed?: boolean; config?: ClawdbotConfig; }): AnyAgentTool[] { - const imageTool = createImageTool({ config: options?.config }); + const imageTool = createImageTool({ + config: options?.config, + agentDir: options?.agentDir, + }); return [ createBrowserTool({ defaultControlUrl: options?.browserControlUrl }), createCanvasTool(), @@ -39,12 +43,12 @@ export function createClawdbotTools(options?: { }), createSessionsSendTool({ agentSessionKey: options?.agentSessionKey, - agentSurface: options?.agentSurface, + agentProvider: options?.agentProvider, sandboxed: options?.sandboxed, }), createSessionsSpawnTool({ agentSessionKey: options?.agentSessionKey, - agentSurface: options?.agentSurface, + agentProvider: options?.agentProvider, sandboxed: options?.sandboxed, }), ...(imageTool ? [imageTool] : []), diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 0564381e4..1716f7800 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -31,15 +31,17 @@ export async function resolveApiKeyForProvider(params: { profileId?: string; preferredProfile?: string; store?: AuthProfileStore; + agentDir?: string; }): Promise<{ apiKey: string; profileId?: string; source: string }> { const { provider, cfg, profileId, preferredProfile } = params; - const store = params.store ?? ensureAuthProfileStore(); + const store = params.store ?? ensureAuthProfileStore(params.agentDir); if (profileId) { const resolved = await resolveApiKeyForProfile({ cfg, store, profileId, + agentDir: params.agentDir, }); if (!resolved) { throw new Error(`No credentials found for profile "${profileId}".`); @@ -63,6 +65,7 @@ export async function resolveApiKeyForProvider(params: { cfg, store, profileId: candidate, + agentDir: params.agentDir, }); if (resolved) { return { @@ -146,6 +149,7 @@ export async function getApiKeyForModel(params: { profileId?: string; preferredProfile?: string; store?: AuthProfileStore; + agentDir?: string; }): Promise<{ apiKey: string; profileId?: string; source: string }> { return resolveApiKeyForProvider({ provider: params.model.provider, @@ -153,5 +157,6 @@ export async function getApiKeyForModel(params: { profileId: params.profileId, preferredProfile: params.preferredProfile, store: params.store, + agentDir: params.agentDir, }); } diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index b3fa0dae7..2887554f5 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -2,10 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { type ClawdbotConfig, loadConfig } from "../config/config.js"; -import { - ensureClawdbotAgentEnv, - resolveClawdbotAgentDir, -} from "./agent-paths.js"; +import { resolveClawdbotAgentDir } from "./agent-paths.js"; type ModelsConfig = NonNullable; @@ -26,15 +23,21 @@ async function readJson(pathname: string): Promise { export async function ensureClawdbotModelsJson( config?: ClawdbotConfig, + agentDirOverride?: string, ): Promise<{ agentDir: string; wrote: boolean }> { const cfg = config ?? loadConfig(); const providers = cfg.models?.providers; if (!providers || Object.keys(providers).length === 0) { - return { agentDir: resolveClawdbotAgentDir(), wrote: false }; + const agentDir = agentDirOverride?.trim() + ? agentDirOverride.trim() + : resolveClawdbotAgentDir(); + return { agentDir, wrote: false }; } const mode = cfg.models?.mode ?? DEFAULT_MODE; - const agentDir = ensureClawdbotAgentEnv(); + const agentDir = agentDirOverride?.trim() + ? agentDirOverride.trim() + : resolveClawdbotAgentDir(); const targetPath = path.join(agentDir, "models.json"); let mergedProviders = providers; diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index b50bf0083..982c702a4 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -335,9 +335,10 @@ function resolvePromptSkills( export async function compactEmbeddedPiSession(params: { sessionId: string; sessionKey?: string; - surface?: string; + messageProvider?: string; sessionFile: string; workspaceDir: string; + agentDir?: string; config?: ClawdbotConfig; skillsSnapshot?: SkillSnapshot; provider?: string; @@ -366,7 +367,7 @@ export async function compactEmbeddedPiSession(params: { (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; await ensureClawdbotModelsJson(params.config); - const agentDir = resolveClawdbotAgentDir(); + const agentDir = params.agentDir ?? resolveClawdbotAgentDir(); const { model, error, authStorage, modelRegistry } = resolveModel( provider, modelId, @@ -440,8 +441,9 @@ export async function compactEmbeddedPiSession(params: { elevated: params.bashElevated, }, sandbox, - surface: params.surface, + messageProvider: params.messageProvider, sessionKey: params.sessionKey ?? params.sessionId, + agentDir, config: params.config, }); const machineName = await getMachineDisplayName(); @@ -544,9 +546,10 @@ export async function compactEmbeddedPiSession(params: { export async function runEmbeddedPiAgent(params: { sessionId: string; sessionKey?: string; - surface?: string; + messageProvider?: string; sessionFile: string; workspaceDir: string; + agentDir?: string; config?: ClawdbotConfig; skillsSnapshot?: SkillSnapshot; prompt: string; @@ -601,7 +604,7 @@ export async function runEmbeddedPiAgent(params: { (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; await ensureClawdbotModelsJson(params.config); - const agentDir = resolveClawdbotAgentDir(); + const agentDir = params.agentDir ?? resolveClawdbotAgentDir(); const { model, error, authStorage, modelRegistry } = resolveModel( provider, modelId, @@ -610,7 +613,7 @@ export async function runEmbeddedPiAgent(params: { if (!model) { throw new Error(error ?? `Unknown model: ${provider}/${modelId}`); } - const authStore = ensureAuthProfileStore(); + const authStore = ensureAuthProfileStore(agentDir); const explicitProfileId = params.authProfileId?.trim(); const profileOrder = resolveAuthProfileOrder({ cfg: params.config, @@ -678,7 +681,7 @@ export async function runEmbeddedPiAgent(params: { attemptedThinking.add(thinkLevel); log.debug( - `embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} surface=${params.surface ?? "unknown"}`, + `embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} messageProvider=${params.messageProvider ?? "unknown"}`, ); await fs.mkdir(resolvedWorkspace, { recursive: true }); @@ -734,8 +737,9 @@ export async function runEmbeddedPiAgent(params: { elevated: params.bashElevated, }, sandbox, - surface: params.surface, + messageProvider: params.messageProvider, sessionKey: params.sessionKey ?? params.sessionId, + agentDir, config: params.config, }); const machineName = await getMachineDisplayName(); diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index 745b73dce..4dd83fdbd 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -100,24 +100,26 @@ describe("createClawdbotCodingTools", () => { expect(offenders).toEqual([]); }); - it("scopes discord tool to discord surface", () => { - const other = createClawdbotCodingTools({ surface: "whatsapp" }); + it("scopes discord tool to discord provider", () => { + const other = createClawdbotCodingTools({ messageProvider: "whatsapp" }); expect(other.some((tool) => tool.name === "discord")).toBe(false); - const discord = createClawdbotCodingTools({ surface: "discord" }); + const discord = createClawdbotCodingTools({ messageProvider: "discord" }); expect(discord.some((tool) => tool.name === "discord")).toBe(true); }); - it("scopes slack tool to slack surface", () => { - const other = createClawdbotCodingTools({ surface: "whatsapp" }); + it("scopes slack tool to slack provider", () => { + const other = createClawdbotCodingTools({ messageProvider: "whatsapp" }); expect(other.some((tool) => tool.name === "slack")).toBe(false); - const slack = createClawdbotCodingTools({ surface: "slack" }); + const slack = createClawdbotCodingTools({ messageProvider: "slack" }); expect(slack.some((tool) => tool.name === "slack")).toBe(true); }); it("filters session tools for sub-agent sessions by default", () => { - const tools = createClawdbotCodingTools({ sessionKey: "subagent:test" }); + const tools = createClawdbotCodingTools({ + sessionKey: "agent:main:subagent:test", + }); const names = new Set(tools.map((tool) => tool.name)); expect(names.has("sessions_list")).toBe(false); expect(names.has("sessions_history")).toBe(false); @@ -131,7 +133,7 @@ describe("createClawdbotCodingTools", () => { it("supports allow-only sub-agent tool policy", () => { const tools = createClawdbotCodingTools({ - sessionKey: "subagent:test", + sessionKey: "agent:main:subagent:test", // Intentionally partial config; only fields used by pi-tools are provided. config: { agent: { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index f438002ce..2dbfb6452 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -9,6 +9,7 @@ import { import { Type } from "@sinclair/typebox"; import type { ClawdbotConfig } from "../config/config.js"; import { detectMime } from "../media/mime.js"; +import { isSubagentSessionKey } from "../routing/session-key.js"; import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js"; import { type BashToolDefaults, @@ -340,11 +341,6 @@ const DEFAULT_SUBAGENT_TOOL_DENY = [ "sessions_spawn", ]; -function isSubagentSessionKey(sessionKey?: string): boolean { - const key = sessionKey?.trim().toLowerCase() ?? ""; - return key.startsWith("subagent:"); -} - function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy { const configured = cfg?.agent?.subagents?.tools; const deny = [ @@ -488,28 +484,31 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool { }; } -function normalizeSurface(surface?: string): string | undefined { - const trimmed = surface?.trim().toLowerCase(); +function normalizeMessageProvider( + messageProvider?: string, +): string | undefined { + const trimmed = messageProvider?.trim().toLowerCase(); return trimmed ? trimmed : undefined; } -function shouldIncludeDiscordTool(surface?: string): boolean { - const normalized = normalizeSurface(surface); +function shouldIncludeDiscordTool(messageProvider?: string): boolean { + const normalized = normalizeMessageProvider(messageProvider); if (!normalized) return false; return normalized === "discord" || normalized.startsWith("discord:"); } -function shouldIncludeSlackTool(surface?: string): boolean { - const normalized = normalizeSurface(surface); +function shouldIncludeSlackTool(messageProvider?: string): boolean { + const normalized = normalizeMessageProvider(messageProvider); if (!normalized) return false; return normalized === "slack" || normalized.startsWith("slack:"); } export function createClawdbotCodingTools(options?: { bash?: BashToolDefaults & ProcessToolDefaults; - surface?: string; + messageProvider?: string; sandbox?: SandboxContext | null; sessionKey?: string; + agentDir?: string; config?: ClawdbotConfig; }): AnyAgentTool[] { const bashToolName = "bash"; @@ -555,13 +554,14 @@ export function createClawdbotCodingTools(options?: { ...createClawdbotTools({ browserControlUrl: sandbox?.browser?.controlUrl, agentSessionKey: options?.sessionKey, - agentSurface: options?.surface, + agentProvider: options?.messageProvider, + agentDir: options?.agentDir, sandboxed: !!sandbox, config: options?.config, }), ]; - const allowDiscord = shouldIncludeDiscordTool(options?.surface); - const allowSlack = shouldIncludeSlackTool(options?.surface); + const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider); + const allowSlack = shouldIncludeSlackTool(options?.messageProvider); const filtered = tools.filter((tool) => { if (tool.name === "discord") return allowDiscord; if (tool.name === "slack") return allowSlack; diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index e39841972..b1a7574e8 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -14,7 +14,6 @@ import { Type } from "@sinclair/typebox"; import type { ClawdbotConfig } from "../../config/config.js"; import { resolveUserPath } from "../../utils.js"; import { loadWebMedia } from "../../web/media.js"; -import { resolveClawdbotAgentDir } from "../agent-paths.js"; import { getApiKeyForModel } from "../model-auth.js"; import { runWithImageModelFallback } from "../model-fallback.js"; import { ensureClawdbotModelsJson } from "../models-config.js"; @@ -78,15 +77,15 @@ function buildImageContext( async function runImagePrompt(params: { cfg?: ClawdbotConfig; + agentDir: string; modelOverride?: string; prompt: string; base64: string; mimeType: string; }): Promise<{ text: string; provider: string; model: string }> { - const agentDir = resolveClawdbotAgentDir(); - await ensureClawdbotModelsJson(params.cfg); - const authStorage = discoverAuthStorage(agentDir); - const modelRegistry = discoverModels(authStorage, agentDir); + await ensureClawdbotModelsJson(params.cfg, params.agentDir); + const authStorage = discoverAuthStorage(params.agentDir); + const modelRegistry = discoverModels(authStorage, params.agentDir); const result = await runWithImageModelFallback({ cfg: params.cfg, @@ -104,6 +103,7 @@ async function runImagePrompt(params: { const apiKeyInfo = await getApiKeyForModel({ model, cfg: params.cfg, + agentDir: params.agentDir, }); authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); const context = buildImageContext( @@ -130,8 +130,13 @@ async function runImagePrompt(params: { export function createImageTool(options?: { config?: ClawdbotConfig; + agentDir?: string; }): AnyAgentTool | null { if (!ensureImageToolConfigured(options?.config)) return null; + const agentDir = options?.agentDir; + if (!agentDir?.trim()) { + throw new Error("createImageTool requires agentDir when enabled"); + } return { label: "Image", name: "image", @@ -175,6 +180,7 @@ export function createImageTool(options?: { const base64 = media.buffer.toString("base64"); const result = await runImagePrompt({ cfg: options?.config, + agentDir, modelOverride, prompt: promptRaw, base64, diff --git a/src/agents/tools/sessions-announce-target.test.ts b/src/agents/tools/sessions-announce-target.test.ts new file mode 100644 index 000000000..490a8c4d2 --- /dev/null +++ b/src/agents/tools/sessions-announce-target.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const callGatewayMock = vi.fn(); +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +import { resolveAnnounceTarget } from "./sessions-announce-target.js"; + +describe("resolveAnnounceTarget", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + }); + + it("derives non-WhatsApp announce targets from the session key", async () => { + const target = await resolveAnnounceTarget({ + sessionKey: "agent:main:discord:group:dev", + displayKey: "agent:main:discord:group:dev", + }); + expect(target).toEqual({ provider: "discord", to: "channel:dev" }); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("hydrates WhatsApp accountId from sessions.list when available", async () => { + callGatewayMock.mockResolvedValueOnce({ + sessions: [ + { + key: "agent:main:whatsapp:group:123@g.us", + lastProvider: "whatsapp", + lastTo: "123@g.us", + lastAccountId: "work", + }, + ], + }); + + const target = await resolveAnnounceTarget({ + sessionKey: "agent:main:whatsapp:group:123@g.us", + displayKey: "agent:main:whatsapp:group:123@g.us", + }); + expect(target).toEqual({ + provider: "whatsapp", + to: "123@g.us", + accountId: "work", + }); + expect(callGatewayMock).toHaveBeenCalledTimes(1); + const first = callGatewayMock.mock.calls[0]?.[0] as + | { method?: string } + | undefined; + expect(first).toBeDefined(); + expect(first?.method).toBe("sessions.list"); + }); +}); diff --git a/src/agents/tools/sessions-announce-target.ts b/src/agents/tools/sessions-announce-target.ts index 2c58363b0..4a0b66dc9 100644 --- a/src/agents/tools/sessions-announce-target.ts +++ b/src/agents/tools/sessions-announce-target.ts @@ -7,9 +7,12 @@ export async function resolveAnnounceTarget(params: { displayKey: string; }): Promise { const parsed = resolveAnnounceTargetFromKey(params.sessionKey); - if (parsed) return parsed; const parsedDisplay = resolveAnnounceTargetFromKey(params.displayKey); - if (parsedDisplay) return parsedDisplay; + const fallback = parsed ?? parsedDisplay ?? null; + + // Most providers can derive (provider,to) from the session key directly. + // WhatsApp is special: we may need lastAccountId from the session store. + if (fallback && fallback.provider !== "whatsapp") return fallback; try { const list = (await callGateway({ @@ -24,13 +27,17 @@ export async function resolveAnnounceTarget(params: { const match = sessions.find((entry) => entry?.key === params.sessionKey) ?? sessions.find((entry) => entry?.key === params.displayKey); - const channel = - typeof match?.lastChannel === "string" ? match.lastChannel : undefined; + const provider = + typeof match?.lastProvider === "string" ? match.lastProvider : undefined; const to = typeof match?.lastTo === "string" ? match.lastTo : undefined; - if (channel && to) return { channel, to }; + const accountId = + typeof match?.lastAccountId === "string" + ? match.lastAccountId + : undefined; + if (provider && to) return { provider, to, accountId }; } catch { // ignore } - return null; + return fallback; } diff --git a/src/agents/tools/sessions-helpers.ts b/src/agents/tools/sessions-helpers.ts index 76fe061c1..c1dbc6f95 100644 --- a/src/agents/tools/sessions-helpers.ts +++ b/src/agents/tools/sessions-helpers.ts @@ -58,8 +58,8 @@ export function classifySessionKind(params: { export function deriveProvider(params: { key: string; kind: SessionKind; - surface?: string | null; - lastChannel?: string | null; + provider?: string | null; + lastProvider?: string | null; }): string { if ( params.kind === "cron" || @@ -67,10 +67,10 @@ export function deriveProvider(params: { params.kind === "node" ) return "internal"; - const surface = normalizeKey(params.surface ?? undefined); - if (surface) return surface; - const lastChannel = normalizeKey(params.lastChannel ?? undefined); - if (lastChannel) return lastChannel; + const provider = normalizeKey(params.provider ?? undefined); + if (provider) return provider; + const lastProvider = normalizeKey(params.lastProvider ?? undefined); + if (lastProvider) return lastProvider; const parts = params.key.split(":").filter(Boolean); if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) { return parts[0]; diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index 9ed9e1470..f35806fe6 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -2,6 +2,11 @@ import { Type } from "@sinclair/typebox"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; +import { + isSubagentSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "../../routing/session-key.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringParam } from "./common.js"; import { @@ -78,7 +83,7 @@ export function createSessionsHistoryTool(opts?: { opts?.sandboxed === true && visibility === "spawned" && requesterInternalKey && - !requesterInternalKey.toLowerCase().startsWith("subagent:"); + !isSubagentSessionKey(requesterInternalKey); if (restrictToSpawned) { const ok = await isSpawnedSessionAllowed({ requesterSessionKey: requesterInternalKey, @@ -91,6 +96,48 @@ export function createSessionsHistoryTool(opts?: { }); } } + + const routingA2A = cfg.routing?.agentToAgent; + const a2aEnabled = routingA2A?.enabled === true; + const allowPatterns = Array.isArray(routingA2A?.allow) + ? routingA2A.allow + : []; + const matchesAllow = (agentId: string) => { + if (allowPatterns.length === 0) return true; + return allowPatterns.some((pattern) => { + const raw = String(pattern ?? "").trim(); + if (!raw) return false; + if (raw === "*") return true; + if (!raw.includes("*")) return raw === agentId; + const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); + return re.test(agentId); + }); + }; + const requesterAgentId = normalizeAgentId( + parseAgentSessionKey(requesterInternalKey)?.agentId, + ); + const targetAgentId = normalizeAgentId( + parseAgentSessionKey(resolvedKey)?.agentId, + ); + const isCrossAgent = requesterAgentId !== targetAgentId; + if (isCrossAgent) { + if (!a2aEnabled) { + return jsonResult({ + status: "forbidden", + error: + "Agent-to-agent history is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent access.", + }); + } + if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) { + return jsonResult({ + status: "forbidden", + error: + "Agent-to-agent history denied by routing.agentToAgent.allow.", + }); + } + } + const limit = typeof params.limit === "number" && Number.isFinite(params.limit) ? Math.max(1, Math.floor(params.limit)) diff --git a/src/agents/tools/sessions-list-tool.gating.test.ts b/src/agents/tools/sessions-list-tool.gating.test.ts new file mode 100644 index 000000000..e375a766f --- /dev/null +++ b/src/agents/tools/sessions-list-tool.gating.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const callGatewayMock = vi.fn(); +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +vi.mock("../../config/config.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadConfig: () => + ({ + session: { scope: "per-sender", mainKey: "main" }, + routing: { agentToAgent: { enabled: false } }, + }) as never, + }; +}); + +import { createSessionsListTool } from "./sessions-list-tool.js"; + +describe("sessions_list gating", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + callGatewayMock.mockResolvedValue({ + path: "/tmp/sessions.json", + sessions: [ + { key: "agent:main:main", kind: "direct" }, + { key: "agent:other:main", kind: "direct" }, + ], + }); + }); + + it("filters out other agents when routing.agentToAgent.enabled is false", async () => { + const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); + const result = await tool.execute("call1", {}); + expect(result.details).toMatchObject({ + count: 1, + sessions: [{ key: "agent:main:main" }], + }); + }); +}); diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index dc2dd14aa..0163f3b04 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -4,6 +4,11 @@ import { Type } from "@sinclair/typebox"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; +import { + isSubagentSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "../../routing/session-key.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringArrayParam } from "./common.js"; import { @@ -31,8 +36,9 @@ type SessionListRow = { systemSent?: boolean; abortedLastRun?: boolean; sendPolicy?: string; - lastChannel?: string; + lastProvider?: string; lastTo?: string; + lastAccountId?: string; transcriptPath?: string; messages?: unknown[]; }; @@ -76,7 +82,7 @@ export function createSessionsListTool(opts?: { opts?.sandboxed === true && visibility === "spawned" && requesterInternalKey && - !requesterInternalKey.toLowerCase().startsWith("subagent:"); + !isSubagentSessionKey(requesterInternalKey); const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) => value.trim().toLowerCase(), @@ -120,12 +126,43 @@ export function createSessionsListTool(opts?: { const sessions = Array.isArray(list?.sessions) ? list.sessions : []; const storePath = typeof list?.path === "string" ? list.path : undefined; + const routingA2A = cfg.routing?.agentToAgent; + const a2aEnabled = routingA2A?.enabled === true; + const allowPatterns = Array.isArray(routingA2A?.allow) + ? routingA2A.allow + : []; + const matchesAllow = (agentId: string) => { + if (allowPatterns.length === 0) return true; + return allowPatterns.some((pattern) => { + const raw = String(pattern ?? "").trim(); + if (!raw) return false; + if (raw === "*") return true; + if (!raw.includes("*")) return raw === agentId; + const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); + return re.test(agentId); + }); + }; + const requesterAgentId = normalizeAgentId( + parseAgentSessionKey(requesterInternalKey)?.agentId, + ); const rows: SessionListRow[] = []; for (const entry of sessions) { if (!entry || typeof entry !== "object") continue; const key = typeof entry.key === "string" ? entry.key : ""; if (!key) continue; + + const entryAgentId = normalizeAgentId( + parseAgentSessionKey(key)?.agentId, + ); + const crossAgent = entryAgentId !== requesterAgentId; + if (crossAgent) { + if (!a2aEnabled) continue; + if (!matchesAllow(requesterAgentId) || !matchesAllow(entryAgentId)) + continue; + } + if (key === "unknown") continue; if (key === "global" && alias !== "global") continue; @@ -140,15 +177,21 @@ export function createSessionsListTool(opts?: { mainKey, }); - const surface = - typeof entry.surface === "string" ? entry.surface : undefined; - const lastChannel = - typeof entry.lastChannel === "string" ? entry.lastChannel : undefined; - const provider = deriveProvider({ + const entryProvider = + typeof entry.provider === "string" ? entry.provider : undefined; + const lastProvider = + typeof entry.lastProvider === "string" + ? entry.lastProvider + : undefined; + const lastAccountId = + typeof entry.lastAccountId === "string" + ? entry.lastAccountId + : undefined; + const derivedProvider = deriveProvider({ key, kind, - surface, - lastChannel, + provider: entryProvider, + lastProvider, }); const sessionId = @@ -161,7 +204,7 @@ export function createSessionsListTool(opts?: { const row: SessionListRow = { key: displayKey, kind, - provider, + provider: derivedProvider, displayName: typeof entry.displayName === "string" ? entry.displayName @@ -196,8 +239,9 @@ export function createSessionsListTool(opts?: { : undefined, sendPolicy: typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined, - lastChannel, + lastProvider, lastTo: typeof entry.lastTo === "string" ? entry.lastTo : undefined, + lastAccountId, transcriptPath, }; diff --git a/src/agents/tools/sessions-send-helpers.ts b/src/agents/tools/sessions-send-helpers.ts index ad47f127a..cc2b995d1 100644 --- a/src/agents/tools/sessions-send-helpers.ts +++ b/src/agents/tools/sessions-send-helpers.ts @@ -6,33 +6,38 @@ const DEFAULT_PING_PONG_TURNS = 5; const MAX_PING_PONG_TURNS = 5; export type AnnounceTarget = { - channel: string; + provider: string; to: string; + accountId?: string; }; export function resolveAnnounceTargetFromKey( sessionKey: string, ): AnnounceTarget | null { - const parts = sessionKey.split(":").filter(Boolean); + const rawParts = sessionKey.split(":").filter(Boolean); + const parts = + rawParts.length >= 3 && rawParts[0] === "agent" + ? rawParts.slice(2) + : rawParts; if (parts.length < 3) return null; - const [surface, kind, ...rest] = parts; + const [providerRaw, kind, ...rest] = parts; if (kind !== "group" && kind !== "channel") return null; const id = rest.join(":").trim(); if (!id) return null; - if (!surface) return null; - const channel = surface.toLowerCase(); - if (channel === "discord") { - return { channel, to: `channel:${id}` }; + if (!providerRaw) return null; + const provider = providerRaw.toLowerCase(); + if (provider === "discord") { + return { provider, to: `channel:${id}` }; } - if (channel === "signal") { - return { channel, to: `group:${id}` }; + if (provider === "signal") { + return { provider, to: `group:${id}` }; } - return { channel, to: id }; + return { provider, to: id }; } export function buildAgentToAgentMessageContext(params: { requesterSessionKey?: string; - requesterSurface?: string; + requesterProvider?: string; targetSessionKey: string; }) { const lines = [ @@ -40,8 +45,8 @@ export function buildAgentToAgentMessageContext(params: { params.requesterSessionKey ? `Agent 1 (requester) session: ${params.requesterSessionKey}.` : undefined, - params.requesterSurface - ? `Agent 1 (requester) surface: ${params.requesterSurface}.` + params.requesterProvider + ? `Agent 1 (requester) provider: ${params.requesterProvider}.` : undefined, `Agent 2 (target) session: ${params.targetSessionKey}.`, ].filter(Boolean); @@ -50,9 +55,9 @@ export function buildAgentToAgentMessageContext(params: { export function buildAgentToAgentReplyContext(params: { requesterSessionKey?: string; - requesterSurface?: string; + requesterProvider?: string; targetSessionKey: string; - targetChannel?: string; + targetProvider?: string; currentRole: "requester" | "target"; turn: number; maxTurns: number; @@ -68,12 +73,12 @@ export function buildAgentToAgentReplyContext(params: { params.requesterSessionKey ? `Agent 1 (requester) session: ${params.requesterSessionKey}.` : undefined, - params.requesterSurface - ? `Agent 1 (requester) surface: ${params.requesterSurface}.` + params.requesterProvider + ? `Agent 1 (requester) provider: ${params.requesterProvider}.` : undefined, `Agent 2 (target) session: ${params.targetSessionKey}.`, - params.targetChannel - ? `Agent 2 (target) surface: ${params.targetChannel}.` + params.targetProvider + ? `Agent 2 (target) provider: ${params.targetProvider}.` : undefined, `If you want to stop the ping-pong, reply exactly "${REPLY_SKIP_TOKEN}".`, ].filter(Boolean); @@ -82,9 +87,9 @@ export function buildAgentToAgentReplyContext(params: { export function buildAgentToAgentAnnounceContext(params: { requesterSessionKey?: string; - requesterSurface?: string; + requesterProvider?: string; targetSessionKey: string; - targetChannel?: string; + targetProvider?: string; originalMessage: string; roundOneReply?: string; latestReply?: string; @@ -94,12 +99,12 @@ export function buildAgentToAgentAnnounceContext(params: { params.requesterSessionKey ? `Agent 1 (requester) session: ${params.requesterSessionKey}.` : undefined, - params.requesterSurface - ? `Agent 1 (requester) surface: ${params.requesterSurface}.` + params.requesterProvider + ? `Agent 1 (requester) provider: ${params.requesterProvider}.` : undefined, `Agent 2 (target) session: ${params.targetSessionKey}.`, - params.targetChannel - ? `Agent 2 (target) surface: ${params.targetChannel}.` + params.targetProvider + ? `Agent 2 (target) provider: ${params.targetProvider}.` : undefined, `Original request: ${params.originalMessage}`, params.roundOneReply @@ -109,7 +114,7 @@ export function buildAgentToAgentAnnounceContext(params: { ? `Latest reply: ${params.latestReply}` : "Latest reply: (not available).", `If you want to remain silent, reply exactly "${ANNOUNCE_SKIP_TOKEN}".`, - "Any other reply will be posted to the target channel.", + "Any other reply will be posted to the target provider.", "After this reply, the agent-to-agent conversation is over.", ].filter(Boolean); return lines.join("\n"); diff --git a/src/agents/tools/sessions-send-tool.gating.test.ts b/src/agents/tools/sessions-send-tool.gating.test.ts new file mode 100644 index 000000000..5137eea71 --- /dev/null +++ b/src/agents/tools/sessions-send-tool.gating.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const callGatewayMock = vi.fn(); +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +vi.mock("../../config/config.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadConfig: () => + ({ + session: { scope: "per-sender", mainKey: "main" }, + routing: { agentToAgent: { enabled: false } }, + }) as never, + }; +}); + +import { createSessionsSendTool } from "./sessions-send-tool.js"; + +describe("sessions_send gating", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + }); + + it("blocks cross-agent sends when routing.agentToAgent.enabled is false", async () => { + const tool = createSessionsSendTool({ + agentSessionKey: "agent:main:main", + agentProvider: "whatsapp", + }); + + const result = await tool.execute("call1", { + sessionKey: "agent:other:main", + message: "hi", + timeoutSeconds: 0, + }); + + expect(callGatewayMock).not.toHaveBeenCalled(); + expect(result.details).toMatchObject({ status: "forbidden" }); + }); +}); diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 72183c896..8c8a4cdec 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -4,6 +4,11 @@ import { Type } from "@sinclair/typebox"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; +import { + isSubagentSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "../../routing/session-key.js"; import { readLatestAssistantReply, runAgentStep } from "./agent-step.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringParam } from "./common.js"; @@ -32,7 +37,7 @@ const SessionsSendToolSchema = Type.Object({ export function createSessionsSendTool(opts?: { agentSessionKey?: string; - agentSurface?: string; + agentProvider?: string; sandboxed?: boolean; }): AnyAgentTool { return { @@ -67,7 +72,7 @@ export function createSessionsSendTool(opts?: { opts?.sandboxed === true && visibility === "spawned" && requesterInternalKey && - !requesterInternalKey.toLowerCase().startsWith("subagent:"); + !isSubagentSessionKey(requesterInternalKey); if (restrictToSpawned) { try { const list = (await callGateway({ @@ -120,9 +125,55 @@ export function createSessionsSendTool(opts?: { alias, mainKey, }); + + const routingA2A = cfg.routing?.agentToAgent; + const a2aEnabled = routingA2A?.enabled === true; + const allowPatterns = Array.isArray(routingA2A?.allow) + ? routingA2A.allow + : []; + const matchesAllow = (agentId: string) => { + if (allowPatterns.length === 0) return true; + return allowPatterns.some((pattern) => { + const raw = String(pattern ?? "").trim(); + if (!raw) return false; + if (raw === "*") return true; + if (!raw.includes("*")) return raw === agentId; + const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); + return re.test(agentId); + }); + }; + const requesterAgentId = normalizeAgentId( + parseAgentSessionKey(requesterInternalKey)?.agentId, + ); + const targetAgentId = normalizeAgentId( + parseAgentSessionKey(resolvedKey)?.agentId, + ); + const isCrossAgent = requesterAgentId !== targetAgentId; + if (isCrossAgent) { + if (!a2aEnabled) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: + "Agent-to-agent messaging is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent sends.", + sessionKey: displayKey, + }); + } + if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: + "Agent-to-agent messaging denied by routing.agentToAgent.allow.", + sessionKey: displayKey, + }); + } + } + const agentMessageContext = buildAgentToAgentMessageContext({ requesterSessionKey: opts?.agentSessionKey, - requesterSurface: opts?.agentSurface, + requesterProvider: opts?.agentProvider, targetSessionKey: displayKey, }); const sendParams = { @@ -134,7 +185,7 @@ export function createSessionsSendTool(opts?: { extraSystemPrompt: agentMessageContext, }; const requesterSessionKey = opts?.agentSessionKey; - const requesterSurface = opts?.agentSurface; + const requesterProvider = opts?.agentProvider; const maxPingPongTurns = resolvePingPongTurns(cfg); const runAgentToAgentFlow = async ( @@ -166,7 +217,7 @@ export function createSessionsSendTool(opts?: { sessionKey: resolvedKey, displayKey, }); - const targetChannel = announceTarget?.channel ?? "unknown"; + const targetProvider = announceTarget?.provider ?? "unknown"; if ( maxPingPongTurns > 0 && requesterSessionKey && @@ -182,9 +233,9 @@ export function createSessionsSendTool(opts?: { : "target"; const replyPrompt = buildAgentToAgentReplyContext({ requesterSessionKey, - requesterSurface, + requesterProvider, targetSessionKey: displayKey, - targetChannel, + targetProvider, currentRole, turn, maxTurns: maxPingPongTurns, @@ -208,9 +259,9 @@ export function createSessionsSendTool(opts?: { } const announcePrompt = buildAgentToAgentAnnounceContext({ requesterSessionKey, - requesterSurface, + requesterProvider, targetSessionKey: displayKey, - targetChannel, + targetProvider, originalMessage: message, roundOneReply: primaryReply, latestReply, @@ -233,7 +284,8 @@ export function createSessionsSendTool(opts?: { params: { to: announceTarget.to, message: announceReply.trim(), - provider: announceTarget.channel, + provider: announceTarget.provider, + accountId: announceTarget.accountId, idempotencyKey: crypto.randomUUID(), }, timeoutMs: 10_000, diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index cd7a97f83..f29569f47 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -4,6 +4,11 @@ import { Type } from "@sinclair/typebox"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; +import { + isSubagentSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "../../routing/session-key.js"; import { readLatestAssistantReply, runAgentStep } from "./agent-step.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringParam } from "./common.js"; @@ -26,7 +31,7 @@ const SessionsSpawnToolSchema = Type.Object({ function buildSubagentSystemPrompt(params: { requesterSessionKey?: string; - requesterSurface?: string; + requesterProvider?: string; childSessionKey: string; label?: string; }) { @@ -36,8 +41,8 @@ function buildSubagentSystemPrompt(params: { params.requesterSessionKey ? `Requester session: ${params.requesterSessionKey}.` : undefined, - params.requesterSurface - ? `Requester surface: ${params.requesterSurface}.` + params.requesterProvider + ? `Requester provider: ${params.requesterProvider}.` : undefined, `Your session: ${params.childSessionKey}.`, "Run the task. Provide a clear final answer (plain text).", @@ -48,7 +53,7 @@ function buildSubagentSystemPrompt(params: { function buildSubagentAnnouncePrompt(params: { requesterSessionKey?: string; - requesterSurface?: string; + requesterProvider?: string; announceChannel: string; task: string; subagentReply?: string; @@ -58,16 +63,16 @@ function buildSubagentAnnouncePrompt(params: { params.requesterSessionKey ? `Requester session: ${params.requesterSessionKey}.` : undefined, - params.requesterSurface - ? `Requester surface: ${params.requesterSurface}.` + params.requesterProvider + ? `Requester provider: ${params.requesterProvider}.` : undefined, - `Post target surface: ${params.announceChannel}.`, + `Post target provider: ${params.announceChannel}.`, `Original task: ${params.task}`, params.subagentReply ? `Sub-agent result: ${params.subagentReply}` : "Sub-agent result: (not available).", 'Reply exactly "ANNOUNCE_SKIP" to stay silent.', - "Any other reply will be posted to the requester chat surface.", + "Any other reply will be posted to the requester chat provider.", ].filter(Boolean); return lines.join("\n"); } @@ -76,7 +81,7 @@ async function runSubagentAnnounceFlow(params: { childSessionKey: string; childRunId: string; requesterSessionKey: string; - requesterSurface?: string; + requesterProvider?: string; requesterDisplayKey: string; task: string; timeoutMs: number; @@ -109,8 +114,8 @@ async function runSubagentAnnounceFlow(params: { const announcePrompt = buildSubagentAnnouncePrompt({ requesterSessionKey: params.requesterSessionKey, - requesterSurface: params.requesterSurface, - announceChannel: announceTarget.channel, + requesterProvider: params.requesterProvider, + announceChannel: announceTarget.provider, task: params.task, subagentReply: reply, }); @@ -135,7 +140,8 @@ async function runSubagentAnnounceFlow(params: { params: { to: announceTarget.to, message: announceReply.trim(), - provider: announceTarget.channel, + provider: announceTarget.provider, + accountId: announceTarget.accountId, idempotencyKey: crypto.randomUUID(), }, timeoutMs: 10_000, @@ -159,7 +165,7 @@ async function runSubagentAnnounceFlow(params: { export function createSessionsSpawnTool(opts?: { agentSessionKey?: string; - agentSurface?: string; + agentProvider?: string; sandboxed?: boolean; }): AnyAgentTool { return { @@ -188,7 +194,7 @@ export function createSessionsSpawnTool(opts?: { const requesterSessionKey = opts?.agentSessionKey; if ( typeof requesterSessionKey === "string" && - requesterSessionKey.trim().toLowerCase().startsWith("subagent:") + isSubagentSessionKey(requesterSessionKey) ) { return jsonResult({ status: "forbidden", @@ -208,7 +214,10 @@ export function createSessionsSpawnTool(opts?: { mainKey, }); - const childSessionKey = `subagent:${crypto.randomUUID()}`; + const requesterAgentId = normalizeAgentId( + parseAgentSessionKey(requesterInternalKey)?.agentId, + ); + const childSessionKey = `agent:${requesterAgentId}:subagent:${crypto.randomUUID()}`; if (opts?.sandboxed === true) { try { await callGateway({ @@ -222,7 +231,7 @@ export function createSessionsSpawnTool(opts?: { } const childSystemPrompt = buildSubagentSystemPrompt({ requesterSessionKey, - requesterSurface: opts?.agentSurface, + requesterProvider: opts?.agentProvider, childSessionKey, label: label || undefined, }); @@ -265,7 +274,7 @@ export function createSessionsSpawnTool(opts?: { childSessionKey, childRunId, requesterSessionKey: requesterInternalKey, - requesterSurface: opts?.agentSurface, + requesterProvider: opts?.agentProvider, requesterDisplayKey, task, timeoutMs: 30_000, @@ -311,7 +320,7 @@ export function createSessionsSpawnTool(opts?: { childSessionKey, childRunId, requesterSessionKey: requesterInternalKey, - requesterSurface: opts?.agentSurface, + requesterProvider: opts?.agentProvider, requesterDisplayKey, task, timeoutMs: 30_000, @@ -329,7 +338,7 @@ export function createSessionsSpawnTool(opts?: { childSessionKey, childRunId, requesterSessionKey: requesterInternalKey, - requesterSurface: opts?.agentSurface, + requesterProvider: opts?.agentProvider, requesterDisplayKey, task, timeoutMs: 30_000, @@ -350,7 +359,7 @@ export function createSessionsSpawnTool(opts?: { childSessionKey, childRunId, requesterSessionKey: requesterInternalKey, - requesterSurface: opts?.agentSurface, + requesterProvider: opts?.agentProvider, requesterDisplayKey, task, timeoutMs: 30_000, diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index 8bef8ad71..de1a3440b 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -47,7 +47,7 @@ describe("chunkText", () => { }); describe("resolveTextChunkLimit", () => { - it("uses per-surface defaults", () => { + it("uses per-provider defaults", () => { expect(resolveTextChunkLimit(undefined, "whatsapp")).toBe(4000); expect(resolveTextChunkLimit(undefined, "telegram")).toBe(4000); expect(resolveTextChunkLimit(undefined, "slack")).toBe(4000); diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index 6dd175f29..8278b0d7f 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -4,7 +4,7 @@ import type { ClawdbotConfig } from "../config/config.js"; -export type TextChunkSurface = +export type TextChunkProvider = | "whatsapp" | "telegram" | "discord" @@ -13,7 +13,7 @@ export type TextChunkSurface = | "imessage" | "webchat"; -const DEFAULT_CHUNK_LIMIT_BY_SURFACE: Record = { +const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record = { whatsapp: 4000, telegram: 4000, discord: 2000, @@ -25,22 +25,22 @@ const DEFAULT_CHUNK_LIMIT_BY_SURFACE: Record = { export function resolveTextChunkLimit( cfg: ClawdbotConfig | undefined, - surface?: TextChunkSurface, + provider?: TextChunkProvider, ): number { - const surfaceOverride = (() => { - if (!surface) return undefined; - if (surface === "whatsapp") return cfg?.whatsapp?.textChunkLimit; - if (surface === "telegram") return cfg?.telegram?.textChunkLimit; - if (surface === "discord") return cfg?.discord?.textChunkLimit; - if (surface === "slack") return cfg?.slack?.textChunkLimit; - if (surface === "signal") return cfg?.signal?.textChunkLimit; - if (surface === "imessage") return cfg?.imessage?.textChunkLimit; + const providerOverride = (() => { + if (!provider) return undefined; + if (provider === "whatsapp") return cfg?.whatsapp?.textChunkLimit; + if (provider === "telegram") return cfg?.telegram?.textChunkLimit; + if (provider === "discord") return cfg?.discord?.textChunkLimit; + if (provider === "slack") return cfg?.slack?.textChunkLimit; + if (provider === "signal") return cfg?.signal?.textChunkLimit; + if (provider === "imessage") return cfg?.imessage?.textChunkLimit; return undefined; })(); - if (typeof surfaceOverride === "number" && surfaceOverride > 0) { - return surfaceOverride; + if (typeof providerOverride === "number" && providerOverride > 0) { + return providerOverride; } - if (surface) return DEFAULT_CHUNK_LIMIT_BY_SURFACE[surface]; + if (provider) return DEFAULT_CHUNK_LIMIT_BY_PROVIDER[provider]; return 4000; } diff --git a/src/auto-reply/command-auth.ts b/src/auto-reply/command-auth.ts index 7599c7390..744971951 100644 --- a/src/auto-reply/command-auth.ts +++ b/src/auto-reply/command-auth.ts @@ -3,7 +3,7 @@ import { normalizeE164 } from "../utils.js"; import type { MsgContext } from "./templating.js"; export type CommandAuthorization = { - isWhatsAppSurface: boolean; + isWhatsAppProvider: boolean; ownerList: string[]; senderE164?: string; isAuthorizedSender: boolean; @@ -17,7 +17,7 @@ export function resolveCommandAuthorization(params: { commandAuthorized: boolean; }): CommandAuthorization { const { ctx, cfg, commandAuthorized } = params; - const surface = (ctx.Surface ?? "").trim().toLowerCase(); + const provider = (ctx.Provider ?? "").trim().toLowerCase(); const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); const hasWhatsappPrefix = @@ -26,30 +26,30 @@ export function resolveCommandAuthorization(params: { const looksLikeE164 = (value: string) => Boolean(value && /^\+?\d{3,}$/.test(value.replace(/[^\d+]/g, ""))); const inferWhatsApp = - !surface && + !provider && Boolean(cfg.whatsapp?.allowFrom?.length) && (looksLikeE164(from) || looksLikeE164(to)); - const isWhatsAppSurface = - surface === "whatsapp" || hasWhatsappPrefix || inferWhatsApp; + const isWhatsAppProvider = + provider === "whatsapp" || hasWhatsappPrefix || inferWhatsApp; - const configuredAllowFrom = isWhatsAppSurface + const configuredAllowFrom = isWhatsAppProvider ? cfg.whatsapp?.allowFrom : undefined; const allowFromList = configuredAllowFrom?.filter((entry) => entry?.trim()) ?? []; const allowAll = - !isWhatsAppSurface || + !isWhatsAppProvider || allowFromList.length === 0 || allowFromList.some((entry) => entry.trim() === "*"); const senderE164 = normalizeE164( - ctx.SenderE164 ?? (isWhatsAppSurface ? from : ""), + ctx.SenderE164 ?? (isWhatsAppProvider ? from : ""), ); const ownerCandidates = - isWhatsAppSurface && !allowAll + isWhatsAppProvider && !allowAll ? allowFromList.filter((entry) => entry !== "*") : []; - if (isWhatsAppSurface && !allowAll && ownerCandidates.length === 0 && to) { + if (isWhatsAppProvider && !allowAll && ownerCandidates.length === 0 && to) { ownerCandidates.push(to); } const ownerList = ownerCandidates @@ -57,14 +57,14 @@ export function resolveCommandAuthorization(params: { .filter((entry): entry is string => Boolean(entry)); const isOwner = - !isWhatsAppSurface || + !isWhatsAppProvider || allowAll || ownerList.length === 0 || (senderE164 ? ownerList.includes(senderE164) : false); const isAuthorizedSender = commandAuthorized && isOwner; return { - isWhatsAppSurface, + isWhatsAppProvider, ownerList, senderE164: senderE164 || undefined, isAuthorizedSender, diff --git a/src/auto-reply/envelope.test.ts b/src/auto-reply/envelope.test.ts index d5ae06674..d1092b142 100644 --- a/src/auto-reply/envelope.test.ts +++ b/src/auto-reply/envelope.test.ts @@ -3,13 +3,13 @@ import { describe, expect, it } from "vitest"; import { formatAgentEnvelope } from "./envelope.js"; describe("formatAgentEnvelope", () => { - it("includes surface, from, ip, host, and timestamp", () => { + it("includes provider, from, ip, host, and timestamp", () => { const originalTz = process.env.TZ; process.env.TZ = "UTC"; const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z const body = formatAgentEnvelope({ - surface: "WebChat", + provider: "WebChat", from: "user1", host: "mac-mini", ip: "10.0.0.5", @@ -30,7 +30,7 @@ describe("formatAgentEnvelope", () => { const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z const body = formatAgentEnvelope({ - surface: "WebChat", + provider: "WebChat", timestamp: ts, body: "hello", }); @@ -41,7 +41,7 @@ describe("formatAgentEnvelope", () => { }); it("handles missing optional fields", () => { - const body = formatAgentEnvelope({ surface: "Telegram", body: "hi" }); + const body = formatAgentEnvelope({ provider: "Telegram", body: "hi" }); expect(body).toBe("[Telegram] hi"); }); }); diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index 6238e5c82..628e13e54 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -1,5 +1,5 @@ export type AgentEnvelopeParams = { - surface: string; + provider: string; from?: string; timestamp?: number | Date; host?: string; @@ -24,8 +24,8 @@ function formatTimestamp(ts?: number | Date): string | undefined { } export function formatAgentEnvelope(params: AgentEnvelopeParams): string { - const surface = params.surface?.trim() || "Surface"; - const parts: string[] = [surface]; + const provider = params.provider?.trim() || "Provider"; + const parts: string[] = [provider]; if (params.from?.trim()) parts.push(params.from.trim()); if (params.host?.trim()) parts.push(params.host.trim()); if (params.ip?.trim()) parts.push(params.ip.trim()); diff --git a/src/auto-reply/reply.block-streaming.test.ts b/src/auto-reply/reply.block-streaming.test.ts index 128681746..caeedc120 100644 --- a/src/auto-reply/reply.block-streaming.test.ts +++ b/src/auto-reply/reply.block-streaming.test.ts @@ -78,7 +78,7 @@ describe("block streaming", () => { From: "+1004", To: "+2000", MessageSid: "msg-123", - Surface: "discord", + Provider: "discord", }, { onReplyStart, @@ -124,7 +124,7 @@ describe("block streaming", () => { From: "+1004", To: "+2000", MessageSid: "msg-124", - Surface: "discord", + Provider: "discord", }, { onBlockReply, diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 062c2099c..bfbc290ca 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -321,7 +321,7 @@ describe("directive parsing", () => { Body: "/elevated maybe", From: "+1222", To: "+1222", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+1222", }, {}, @@ -709,7 +709,7 @@ describe("directive parsing", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toContain("Model set to openai/gpt-4.1-mini"); const store = loadSessionStore(storePath); - const entry = store.main; + const entry = store["agent:main:main"]; expect(entry.modelOverride).toBe("gpt-4.1-mini"); expect(entry.providerOverride).toBe("openai"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); @@ -741,7 +741,7 @@ describe("directive parsing", () => { expect(text).toContain("Model set to Opus"); expect(text).toContain("anthropic/claude-opus-4-5"); const store = loadSessionStore(storePath); - const entry = store.main; + const entry = store["agent:main:main"]; expect(entry.modelOverride).toBe("claude-opus-4-5"); expect(entry.providerOverride).toBe("anthropic"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); @@ -791,7 +791,7 @@ describe("directive parsing", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toContain("Auth profile set to anthropic:work"); const store = loadSessionStore(storePath); - const entry = store.main; + const entry = store["agent:main:main"]; expect(entry.authProfileOverride).toBe("anthropic:work"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); @@ -932,7 +932,7 @@ describe("directive parsing", () => { Body: "hello", From: "+1004", To: "+2000", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+1004", }, {}, diff --git a/src/auto-reply/reply.heartbeat-typing.test.ts b/src/auto-reply/reply.heartbeat-typing.test.ts index 190b094b8..27cd335f1 100644 --- a/src/auto-reply/reply.heartbeat-typing.test.ts +++ b/src/auto-reply/reply.heartbeat-typing.test.ts @@ -82,7 +82,7 @@ describe("getReplyFromConfig typing (heartbeat)", () => { const onReplyStart = vi.fn(); await getReplyFromConfig( - { Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" }, + { Body: "hi", From: "+1000", To: "+2000", Provider: "whatsapp" }, { onReplyStart, isHeartbeat: false }, makeCfg(home), ); @@ -100,7 +100,7 @@ describe("getReplyFromConfig typing (heartbeat)", () => { const onReplyStart = vi.fn(); await getReplyFromConfig( - { Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" }, + { Body: "hi", From: "+1000", To: "+2000", Provider: "whatsapp" }, { onReplyStart, isHeartbeat: true }, makeCfg(home), ); diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 006bfdaca..62855043c 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -23,6 +23,8 @@ import { loadSessionStore, resolveSessionKey } from "../config/sessions.js"; import { getReplyFromConfig } from "./reply.js"; import { HEARTBEAT_TOKEN } from "./tokens.js"; +const MAIN_SESSION_KEY = "agent:main:main"; + const webMocks = vi.hoisted(() => ({ webAuthExists: vi.fn().mockResolvedValue(true), getWebAuthAgeMs: vi.fn().mockReturnValue(120_000), @@ -166,7 +168,7 @@ describe("trigger handling", () => { Body: "/send off", From: "+1000", To: "+2000", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+1000", }, {}, @@ -180,7 +182,7 @@ describe("trigger handling", () => { string, { sendPolicy?: string } >; - expect(store.main?.sendPolicy).toBe("deny"); + expect(store[MAIN_SESSION_KEY]?.sendPolicy).toBe("deny"); }); }); @@ -205,7 +207,7 @@ describe("trigger handling", () => { Body: "/elevated on", From: "+1000", To: "+2000", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+1000", }, {}, @@ -219,7 +221,7 @@ describe("trigger handling", () => { string, { elevatedLevel?: string } >; - expect(store.main?.elevatedLevel).toBe("on"); + expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); }); }); @@ -245,7 +247,7 @@ describe("trigger handling", () => { Body: "/elevated on", From: "+1000", To: "+2000", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+1000", }, {}, @@ -259,7 +261,7 @@ describe("trigger handling", () => { string, { elevatedLevel?: string } >; - expect(store.main?.elevatedLevel).toBeUndefined(); + expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBeUndefined(); }); }); @@ -284,7 +286,7 @@ describe("trigger handling", () => { Body: "please /elevated on now", From: "+2000", To: "+2000", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+2000", }, {}, @@ -316,7 +318,7 @@ describe("trigger handling", () => { Body: "/elevated on", From: "discord:123", To: "user:123", - Surface: "discord", + Provider: "discord", SenderName: "Peter Steinberger", SenderUsername: "steipete", SenderTag: "steipete", @@ -332,7 +334,7 @@ describe("trigger handling", () => { string, { elevatedLevel?: string } >; - expect(store.main?.elevatedLevel).toBe("on"); + expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); }); }); @@ -359,7 +361,7 @@ describe("trigger handling", () => { Body: "/elevated on", From: "discord:123", To: "user:123", - Surface: "discord", + Provider: "discord", SenderName: "steipete", }, {}, @@ -510,7 +512,7 @@ describe("trigger handling", () => { From: "123@g.us", To: "+2000", ChatType: "group", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+2000", }, {}, @@ -521,7 +523,9 @@ describe("trigger handling", () => { const store = JSON.parse( await fs.readFile(cfg.session.store, "utf-8"), ) as Record; - expect(store["whatsapp:group:123@g.us"]?.groupActivation).toBe("always"); + expect(store["agent:main:whatsapp:group:123@g.us"]?.groupActivation).toBe( + "always", + ); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -535,7 +539,7 @@ describe("trigger handling", () => { From: "123@g.us", To: "+2000", ChatType: "group", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+999", }, {}, @@ -563,7 +567,7 @@ describe("trigger handling", () => { From: "123@g.us", To: "+2000", ChatType: "group", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+2000", GroupSubject: "Test Group", GroupMembers: "Alice (+1), Bob (+2)", @@ -879,7 +883,7 @@ describe("trigger handling", () => { From: "group:whatsapp:demo", To: "+2000", ChatType: "group" as const, - Surface: "whatsapp" as const, + Provider: "whatsapp" as const, MediaPath: mediaPath, MediaType: "image/jpeg", MediaUrl: mediaPath, @@ -942,7 +946,7 @@ describe("group intro prompts", () => { ChatType: "group", GroupSubject: "Release Squad", GroupMembers: "Alice, Bob", - Surface: "discord", + Provider: "discord", }, {}, makeCfg(home), @@ -975,7 +979,7 @@ describe("group intro prompts", () => { To: "+1999", ChatType: "group", GroupSubject: "Ops", - Surface: "whatsapp", + Provider: "whatsapp", }, {}, makeCfg(home), @@ -1008,7 +1012,7 @@ describe("group intro prompts", () => { To: "+1777", ChatType: "group", GroupSubject: "Dev Chat", - Surface: "telegram", + Provider: "telegram", }, {}, makeCfg(home), diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index b772a2414..c27395eb4 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -2,7 +2,11 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; - +import { + resolveAgentDir, + resolveAgentIdFromSessionKey, + resolveAgentWorkspaceDir, +} from "../agents/agent-scope.js"; import { resolveModelRefFromString } from "../agents/model-selection.js"; import { abortEmbeddedPiRun, @@ -108,10 +112,10 @@ function stripSenderPrefix(value?: string) { function resolveElevatedAllowList( allowFrom: AgentElevatedAllowFromConfig | undefined, - surface: string, + provider: string, discordFallback?: Array, ): Array | undefined { - switch (surface) { + switch (provider) { case "whatsapp": return allowFrom?.whatsapp; case "telegram": @@ -135,14 +139,14 @@ function resolveElevatedAllowList( } function isApprovedElevatedSender(params: { - surface: string; + provider: string; ctx: MsgContext; allowFrom?: AgentElevatedAllowFromConfig; discordFallback?: Array; }): boolean { const rawAllow = resolveElevatedAllowList( params.allowFrom, - params.surface, + params.provider, params.discordFallback, ); if (!rawAllow || rawAllow.length === 0) return false; @@ -216,12 +220,15 @@ export async function getReplyFromConfig( } } - const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey); + const workspaceDirRaw = + resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR; const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, ensureBootstrapFiles: !cfg.agent?.skipBootstrap, }); const workspaceDir = workspace.dir; + const agentDir = resolveAgentDir(cfg, agentId); const timeoutMs = resolveAgentTimeoutMs({ cfg }); const configuredTypingSeconds = agentCfg?.typingIntervalSeconds ?? sessionCfg?.typingIntervalSeconds; @@ -289,20 +296,20 @@ export async function getReplyFromConfig( sessionCtx.Body = parsedDirectives.cleaned; sessionCtx.BodyStripped = parsedDirectives.cleaned; - const surfaceKey = - sessionCtx.Surface?.trim().toLowerCase() ?? - ctx.Surface?.trim().toLowerCase() ?? + const messageProviderKey = + sessionCtx.Provider?.trim().toLowerCase() ?? + ctx.Provider?.trim().toLowerCase() ?? ""; const elevatedConfig = agentCfg?.elevated; const discordElevatedFallback = - surfaceKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined; + messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined; const elevatedEnabled = elevatedConfig?.enabled !== false; const elevatedAllowed = elevatedEnabled && Boolean( - surfaceKey && + messageProviderKey && isApprovedElevatedSender({ - surface: surfaceKey, + provider: messageProviderKey, ctx, allowFrom: elevatedConfig?.allowFrom, discordFallback: discordElevatedFallback, @@ -345,7 +352,7 @@ export async function getReplyFromConfig( : "text_end"; const blockStreamingEnabled = resolvedBlockStreaming === "on"; const blockReplyChunking = blockStreamingEnabled - ? resolveBlockStreamingChunking(cfg, sessionCtx.Surface) + ? resolveBlockStreamingChunking(cfg, sessionCtx.Provider) : undefined; const modelState = await createModelSelectionState({ @@ -463,7 +470,7 @@ export async function getReplyFromConfig( }); const isEmptyConfig = Object.keys(cfg).length === 0; if ( - command.isWhatsAppSurface && + command.isWhatsAppProvider && isEmptyConfig && command.from && command.to && @@ -638,7 +645,7 @@ export async function getReplyFromConfig( : queueBodyBase; const resolvedQueue = resolveQueueSettings({ cfg, - surface: sessionCtx.Surface, + provider: sessionCtx.Provider, sessionEntry, inlineMode: perMessageQueueMode, inlineOptions: perMessageQueueOptions, @@ -669,9 +676,11 @@ export async function getReplyFromConfig( summaryLine: baseBodyTrimmedRaw, enqueuedAt: Date.now(), run: { + agentId, + agentDir, sessionId: sessionIdFinal, sessionKey, - surface: sessionCtx.Surface?.trim().toLowerCase() || undefined, + messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined, sessionFile, workspaceDir, config: cfg, diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts index b0abb516d..85eb5cf84 100644 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts +++ b/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts @@ -71,7 +71,7 @@ function createMinimalRun(params?: { const typing = createTyping(); const opts = params?.opts; const sessionCtx = { - Surface: "whatsapp", + Provider: "whatsapp", MessageSid: "msg", } as unknown as TemplateContext; const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; @@ -83,7 +83,7 @@ function createMinimalRun(params?: { run: { sessionId: "session", sessionKey, - surface: "whatsapp", + messageProvider: "whatsapp", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", config: {}, diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 21eadb12c..29fab4b55 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -186,9 +186,11 @@ export async function runReplyAgent(params: { runEmbeddedPiAgent({ sessionId: followupRun.run.sessionId, sessionKey, - surface: sessionCtx.Surface?.trim().toLowerCase() || undefined, + messageProvider: + sessionCtx.Provider?.trim().toLowerCase() || undefined, sessionFile: followupRun.run.sessionFile, workspaceDir: followupRun.run.workspaceDir, + agentDir: followupRun.run.agentDir, config: followupRun.run.config, skillsSnapshot: followupRun.run.skillsSnapshot, prompt: commandBody, diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index 6388363ee..ea231c04c 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -1,10 +1,10 @@ import type { ClawdbotConfig } from "../../config/config.js"; -import { resolveTextChunkLimit, type TextChunkSurface } from "../chunk.js"; +import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js"; const DEFAULT_BLOCK_STREAM_MIN = 800; const DEFAULT_BLOCK_STREAM_MAX = 1200; -const BLOCK_CHUNK_SURFACES = new Set([ +const BLOCK_CHUNK_PROVIDERS = new Set([ "whatsapp", "telegram", "discord", @@ -14,24 +14,26 @@ const BLOCK_CHUNK_SURFACES = new Set([ "webchat", ]); -function normalizeChunkSurface(surface?: string): TextChunkSurface | undefined { - if (!surface) return undefined; - const cleaned = surface.trim().toLowerCase(); - return BLOCK_CHUNK_SURFACES.has(cleaned as TextChunkSurface) - ? (cleaned as TextChunkSurface) +function normalizeChunkProvider( + provider?: string, +): TextChunkProvider | undefined { + if (!provider) return undefined; + const cleaned = provider.trim().toLowerCase(); + return BLOCK_CHUNK_PROVIDERS.has(cleaned as TextChunkProvider) + ? (cleaned as TextChunkProvider) : undefined; } export function resolveBlockStreamingChunking( cfg: ClawdbotConfig | undefined, - surface?: string, + provider?: string, ): { minChars: number; maxChars: number; breakPreference: "paragraph" | "newline" | "sentence"; } { - const surfaceKey = normalizeChunkSurface(surface); - const textLimit = resolveTextChunkLimit(cfg, surfaceKey); + const providerKey = normalizeChunkProvider(provider); + const textLimit = resolveTextChunkLimit(cfg, providerKey); const chunkCfg = cfg?.agent?.blockStreamingChunk; const maxRequested = Math.max( 1, diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 7ade976b8..b9a560f69 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -47,8 +47,8 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { incrementCompactionCount } from "./session-updates.js"; export type CommandContext = { - surface: string; - isWhatsAppSurface: boolean; + provider: string; + isWhatsAppProvider: boolean; ownerList: string[]; isAuthorizedSender: boolean; senderE164?: string; @@ -123,7 +123,7 @@ export function buildCommandContext(params: { cfg, commandAuthorized: params.commandAuthorized, }); - const surface = (ctx.Surface ?? "").trim().toLowerCase(); + const provider = (ctx.Provider ?? "").trim().toLowerCase(); const abortKey = sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined); const rawBodyNormalized = triggerBodyNormalized; @@ -132,8 +132,8 @@ export function buildCommandContext(params: { : rawBodyNormalized; return { - surface, - isWhatsAppSurface: auth.isWhatsAppSurface, + provider, + isWhatsAppProvider: auth.isWhatsAppProvider, ownerList: auth.ownerList, isAuthorizedSender: auth.isAuthorizedSender, senderE164: auth.senderE164, @@ -220,14 +220,14 @@ export async function handleCommands(params: { ? normalizeE164(command.senderE164) : ""; const isActivationOwner = - !command.isWhatsAppSurface || activationOwnerList.length === 0 + !command.isWhatsAppProvider || activationOwnerList.length === 0 ? command.isAuthorizedSender : Boolean(activationSenderE164) && activationOwnerList.includes(activationSenderE164); if ( !command.isAuthorizedSender || - (command.isWhatsAppSurface && !isActivationOwner) + (command.isWhatsAppProvider && !isActivationOwner) ) { logVerbose( `Ignoring /activation from unauthorized sender in group: ${command.senderE164 || ""}`, @@ -402,7 +402,7 @@ export async function handleCommands(params: { const result = await compactEmbeddedPiSession({ sessionId, sessionKey, - surface: command.surface, + messageProvider: command.provider, sessionFile: resolveSessionTranscriptPath(sessionId), workspaceDir, config: cfg, @@ -469,7 +469,7 @@ export async function handleCommands(params: { cfg, entry: sessionEntry, sessionKey, - surface: sessionEntry?.surface ?? command.surface, + provider: sessionEntry?.provider ?? command.provider, chatType: sessionEntry?.chatType, }); if (sendPolicy === "deny") { diff --git a/src/auto-reply/reply/followup-runner.compaction.test.ts b/src/auto-reply/reply/followup-runner.compaction.test.ts index 481f42f8b..6c319a310 100644 --- a/src/auto-reply/reply/followup-runner.compaction.test.ts +++ b/src/auto-reply/reply/followup-runner.compaction.test.ts @@ -90,7 +90,7 @@ describe("createFollowupRunner compaction", () => { run: { sessionId: "session", sessionKey: "main", - surface: "whatsapp", + messageProvider: "whatsapp", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", config: {}, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 9a6be5bd8..a6cb82da9 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -76,7 +76,7 @@ export function createFollowupRunner(params: { runEmbeddedPiAgent({ sessionId: queued.run.sessionId, sessionKey: queued.run.sessionKey, - surface: queued.run.surface, + messageProvider: queued.run.messageProvider, sessionFile: queued.run.sessionFile, workspaceDir: queued.run.workspaceDir, config: queued.run.config, diff --git a/src/auto-reply/reply/groups.test.ts b/src/auto-reply/reply/groups.test.ts index a7fde10a3..d950731ef 100644 --- a/src/auto-reply/reply/groups.test.ts +++ b/src/auto-reply/reply/groups.test.ts @@ -19,13 +19,13 @@ describe("resolveGroupRequireMention", () => { }, }; const ctx: TemplateContext = { - Surface: "discord", + Provider: "discord", From: "group:123", GroupRoom: "#general", GroupSpace: "145", }; const groupResolution: GroupKeyResolution = { - surface: "discord", + provider: "discord", id: "123", chatType: "group", }; @@ -44,12 +44,12 @@ describe("resolveGroupRequireMention", () => { }, }; const ctx: TemplateContext = { - Surface: "slack", + Provider: "slack", From: "slack:channel:C123", GroupSubject: "#general", }; const groupResolution: GroupKeyResolution = { - surface: "slack", + provider: "slack", id: "C123", chatType: "group", }; diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index fd9ccd40f..9569f1f1e 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -50,22 +50,23 @@ export function resolveGroupRequireMention(params: { groupResolution?: GroupKeyResolution; }): boolean { const { cfg, ctx, groupResolution } = params; - const surface = groupResolution?.surface ?? ctx.Surface?.trim().toLowerCase(); + const provider = + groupResolution?.provider ?? ctx.Provider?.trim().toLowerCase(); const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, ""); const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim(); const groupSpace = ctx.GroupSpace?.trim(); if ( - surface === "telegram" || - surface === "whatsapp" || - surface === "imessage" + provider === "telegram" || + provider === "whatsapp" || + provider === "imessage" ) { return resolveProviderGroupRequireMention({ cfg, - surface, + provider, groupId, }); } - if (surface === "discord") { + if (provider === "discord") { const guildEntry = resolveDiscordGuildEntry( cfg.discord?.guilds, groupSpace, @@ -90,7 +91,7 @@ export function resolveGroupRequireMention(params: { } return true; } - if (surface === "slack") { + if (provider === "slack") { const channels = cfg.slack?.channels ?? {}; const keys = Object.keys(channels); if (keys.length === 0) return true; @@ -137,18 +138,18 @@ export function buildGroupIntro(params: { params.defaultActivation; const subject = params.sessionCtx.GroupSubject?.trim(); const members = params.sessionCtx.GroupMembers?.trim(); - const surface = params.sessionCtx.Surface?.trim().toLowerCase(); - const surfaceLabel = (() => { - if (!surface) return "chat"; - if (surface === "whatsapp") return "WhatsApp"; - if (surface === "telegram") return "Telegram"; - if (surface === "discord") return "Discord"; - if (surface === "webchat") return "WebChat"; - return `${surface.at(0)?.toUpperCase() ?? ""}${surface.slice(1)}`; + const provider = params.sessionCtx.Provider?.trim().toLowerCase(); + const providerLabel = (() => { + if (!provider) return "chat"; + if (provider === "whatsapp") return "WhatsApp"; + if (provider === "telegram") return "Telegram"; + if (provider === "discord") return "Discord"; + if (provider === "webchat") return "WebChat"; + return `${provider.at(0)?.toUpperCase() ?? ""}${provider.slice(1)}`; })(); const subjectLine = subject - ? `You are replying inside the ${surfaceLabel} group "${subject}".` - : `You are replying inside a ${surfaceLabel} group chat.`; + ? `You are replying inside the ${providerLabel} group "${subject}".` + : `You are replying inside a ${providerLabel} group chat.`; const membersLine = members ? `Group members: ${members}.` : undefined; const activationLine = activation === "always" diff --git a/src/auto-reply/reply/queue.ts b/src/auto-reply/reply/queue.ts index 47b0dc432..2d9093cc5 100644 --- a/src/auto-reply/reply/queue.ts +++ b/src/auto-reply/reply/queue.ts @@ -23,9 +23,11 @@ export type FollowupRun = { summaryLine?: string; enqueuedAt: number; run: { + agentId: string; + agentDir: string; sessionId: string; sessionKey?: string; - surface?: string; + messageProvider?: string; sessionFile: string; workspaceDir: string; config: ClawdbotConfig; @@ -425,8 +427,8 @@ export function scheduleFollowupDrain( } })(); } -function defaultQueueModeForSurface(surface?: string): QueueMode { - const normalized = surface?.trim().toLowerCase(); +function defaultQueueModeForProvider(provider?: string): QueueMode { + const normalized = provider?.trim().toLowerCase(); if (normalized === "discord") return "collect"; if (normalized === "webchat") return "collect"; if (normalized === "whatsapp") return "collect"; @@ -437,23 +439,23 @@ function defaultQueueModeForSurface(surface?: string): QueueMode { } export function resolveQueueSettings(params: { cfg: ClawdbotConfig; - surface?: string; + provider?: string; sessionEntry?: SessionEntry; inlineMode?: QueueMode; inlineOptions?: Partial; }): QueueSettings { - const surfaceKey = params.surface?.trim().toLowerCase(); + const providerKey = params.provider?.trim().toLowerCase(); const queueCfg = params.cfg.routing?.queue; - const surfaceModeRaw = - surfaceKey && queueCfg?.bySurface - ? (queueCfg.bySurface as Record)[surfaceKey] + const providerModeRaw = + providerKey && queueCfg?.byProvider + ? (queueCfg.byProvider as Record)[providerKey] : undefined; const resolvedMode = params.inlineMode ?? normalizeQueueMode(params.sessionEntry?.queueMode) ?? - normalizeQueueMode(surfaceModeRaw) ?? + normalizeQueueMode(providerModeRaw) ?? normalizeQueueMode(queueCfg?.mode) ?? - defaultQueueModeForSurface(surfaceKey); + defaultQueueModeForProvider(providerKey); const debounceRaw = params.inlineOptions?.debounceMs ?? params.sessionEntry?.queueDebounceMs ?? diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index a6d4f0357..992fb2f61 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -7,6 +7,7 @@ import { DEFAULT_RESET_TRIGGERS, type GroupKeyResolution, loadSessionStore, + resolveAgentIdFromSessionKey, resolveGroupSessionKey, resolveSessionKey, resolveStorePath, @@ -43,6 +44,7 @@ export async function initSessionState(params: { const { ctx, cfg, commandAuthorized } = params; const sessionCfg = cfg.session; const mainKey = sessionCfg?.mainKey ?? "main"; + const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey); const resetTriggers = sessionCfg?.resetTriggers?.length ? sessionCfg.resetTriggers : DEFAULT_RESET_TRIGGERS; @@ -51,12 +53,12 @@ export async function initSessionState(params: { 1, ); const sessionScope = sessionCfg?.scope ?? "per-sender"; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = resolveStorePath(sessionCfg?.store, { agentId }); const sessionStore: Record = loadSessionStore(storePath); let sessionKey: string | undefined; - let sessionEntry: SessionEntry | undefined; + let sessionEntry: SessionEntry; let sessionId: string | undefined; let isNewSession = false; @@ -154,30 +156,30 @@ export async function initSessionState(params: { queueDrop: baseEntry?.queueDrop, displayName: baseEntry?.displayName, chatType: baseEntry?.chatType, - surface: baseEntry?.surface, + provider: baseEntry?.provider, subject: baseEntry?.subject, room: baseEntry?.room, space: baseEntry?.space, }; - if (groupResolution?.surface) { - const surface = groupResolution.surface; + if (groupResolution?.provider) { + const provider = groupResolution.provider; const subject = ctx.GroupSubject?.trim(); const space = ctx.GroupSpace?.trim(); const explicitRoom = ctx.GroupRoom?.trim(); - const isRoomSurface = surface === "discord" || surface === "slack"; + const isRoomProvider = provider === "discord" || provider === "slack"; const nextRoom = explicitRoom ?? - (isRoomSurface && subject && subject.startsWith("#") + (isRoomProvider && subject && subject.startsWith("#") ? subject : undefined); const nextSubject = nextRoom ? undefined : subject; sessionEntry.chatType = groupResolution.chatType ?? "group"; - sessionEntry.surface = surface; + sessionEntry.provider = provider; if (nextSubject) sessionEntry.subject = nextSubject; if (nextRoom) sessionEntry.room = nextRoom; if (space) sessionEntry.space = space; sessionEntry.displayName = buildGroupDisplayName({ - surface: sessionEntry.surface, + provider: sessionEntry.provider, subject: sessionEntry.subject, room: sessionEntry.room, space: sessionEntry.space, diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 3d4bc9587..878065913 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -24,7 +24,7 @@ describe("buildStatusMessage", () => { verboseLevel: "on", compactionCount: 2, }, - sessionKey: "main", + sessionKey: "agent:main:main", sessionScope: "per-sender", storePath: "/tmp/sessions.json", resolvedThink: "medium", @@ -39,7 +39,7 @@ describe("buildStatusMessage", () => { expect(text).toContain("Agent: embedded pi"); expect(text).toContain("Runtime: direct"); expect(text).toContain("Context: 16k/32k (50%)"); - expect(text).toContain("Session: main"); + expect(text).toContain("Session: agent:main:main"); expect(text).toContain("compactions 2"); expect(text).toContain("Web: linked"); expect(text).toContain("heartbeat 45s"); @@ -70,7 +70,7 @@ describe("buildStatusMessage", () => { groupActivation: "always", chatType: "group", }, - sessionKey: "whatsapp:group:123@g.us", + sessionKey: "agent:main:whatsapp:group:123@g.us", sessionScope: "per-sender", webLinked: true, }); @@ -91,6 +91,8 @@ describe("buildStatusMessage", () => { const storePath = path.join( dir, ".clawdbot", + "agents", + "main", "sessions", "sessions.json", ); @@ -98,6 +100,8 @@ describe("buildStatusMessage", () => { const logPath = path.join( dir, ".clawdbot", + "agents", + "main", "sessions", `${sessionId}.jsonl`, ); @@ -135,7 +139,7 @@ describe("buildStatusMessage", () => { totalTokens: 3, // would be wrong if cached prompt tokens exist contextTokens: 32_000, }, - sessionKey: "main", + sessionKey: "agent:main:main", sessionScope: "per-sender", storePath, webLinked: true, diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index d2987ec22..369cf9138 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -3,6 +3,8 @@ export type MsgContext = { From?: string; To?: string; SessionKey?: string; + /** Provider account id (multi-account). */ + AccountId?: string; MessageSid?: string; ReplyToId?: string; ReplyToBody?: string; @@ -24,7 +26,8 @@ export type MsgContext = { SenderUsername?: string; SenderTag?: string; SenderE164?: string; - Surface?: string; + /** Provider label (whatsapp|telegram|discord|imessage|...). */ + Provider?: string; WasMentioned?: boolean; CommandAuthorized?: boolean; }; diff --git a/src/cli/cron-cli.ts b/src/cli/cron-cli.ts index 94076ea3d..bca2b160f 100644 --- a/src/cli/cron-cli.ts +++ b/src/cli/cron-cli.ts @@ -154,8 +154,8 @@ export function registerCronCli(program: Command) { .option("--timeout-seconds ", "Timeout seconds for agent jobs") .option("--deliver", "Deliver agent output", false) .option( - "--channel ", - "Delivery channel (last|whatsapp|telegram|discord|slack|signal|imessage)", + "--provider ", + "Delivery provider (last|whatsapp|telegram|discord|slack|signal|imessage)", "last", ) .option( @@ -255,7 +255,8 @@ export function registerCronCli(program: Command) { ? timeoutSeconds : undefined, deliver: Boolean(opts.deliver), - channel: typeof opts.channel === "string" ? opts.channel : "last", + provider: + typeof opts.provider === "string" ? opts.provider : "last", to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() @@ -413,8 +414,8 @@ export function registerCronCli(program: Command) { .option("--timeout-seconds ", "Timeout seconds for agent jobs") .option("--deliver", "Deliver agent output", false) .option( - "--channel ", - "Delivery channel (last|whatsapp|telegram|discord|slack|signal|imessage)", + "--provider ", + "Delivery provider (last|whatsapp|telegram|discord|slack|signal|imessage)", ) .option( "--to ", @@ -502,8 +503,8 @@ export function registerCronCli(program: Command) { ? timeoutSeconds : undefined, deliver: Boolean(opts.deliver), - channel: - typeof opts.channel === "string" ? opts.channel : undefined, + provider: + typeof opts.provider === "string" ? opts.provider : undefined, to: typeof opts.to === "string" ? opts.to : undefined, bestEffortDeliver: Boolean(opts.bestEffortDeliver), }; diff --git a/src/cli/program.ts b/src/cli/program.ts index 4988dfdf5..d66fb0738 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -13,6 +13,7 @@ import { statusCommand } from "../commands/status.js"; import { updateCommand } from "../commands/update.js"; import { isNixMode, + loadConfig, migrateLegacyConfig, readConfigFileSnapshot, writeConfigFile, @@ -21,6 +22,7 @@ import { danger, setVerbose } from "../globals.js"; import { loginWeb, logoutWeb } from "../provider-web.js"; import { defaultRuntime } from "../runtime.js"; import { VERSION } from "../version.js"; +import { resolveWhatsAppAccount } from "../web/accounts.js"; import { registerBrowserCli } from "./browser-cli.js"; import { registerCanvasCli } from "./canvas-cli.js"; import { registerCronCli } from "./cron-cli.js"; @@ -324,11 +326,18 @@ export function buildProgram() { .description("Link your personal WhatsApp via QR (web provider)") .option("--verbose", "Verbose connection logs", false) .option("--provider ", "Provider alias (default: whatsapp)") + .option("--account ", "WhatsApp account id (accountId)") .action(async (opts) => { setVerbose(Boolean(opts.verbose)); try { const provider = opts.provider ?? "whatsapp"; - await loginWeb(Boolean(opts.verbose), provider); + await loginWeb( + Boolean(opts.verbose), + provider, + undefined, + defaultRuntime, + opts.account as string | undefined, + ); } catch (err) { defaultRuntime.error(danger(`Web login failed: ${String(err)}`)); defaultRuntime.exit(1); @@ -339,10 +348,20 @@ export function buildProgram() { .command("logout") .description("Clear cached WhatsApp Web credentials") .option("--provider ", "Provider alias (default: whatsapp)") + .option("--account ", "WhatsApp account id (accountId)") .action(async (opts) => { try { void opts.provider; // placeholder for future multi-provider; currently web only. - await logoutWeb(defaultRuntime); + const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ + cfg, + accountId: opts.account as string | undefined, + }); + await logoutWeb({ + runtime: defaultRuntime, + authDir: account.authDir, + isLegacyAuthDir: account.isLegacyAuthDir, + }); } catch (err) { defaultRuntime.error(danger(`Logout failed: ${String(err)}`)); defaultRuntime.exit(1); @@ -372,6 +391,7 @@ export function buildProgram() { "--provider ", "Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)", ) + .option("--account ", "WhatsApp account id (accountId)") .option("--dry-run", "Print payload and skip sending", false) .option("--json", "Output result as JSON", false) .option("--verbose", "Verbose logging", false) @@ -388,7 +408,14 @@ Examples: setVerbose(Boolean(opts.verbose)); const deps = createDefaultDeps(); try { - await sendCommand(opts, deps, defaultRuntime); + await sendCommand( + { + ...opts, + account: opts.account as string | undefined, + }, + deps, + defaultRuntime, + ); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index e0084de5e..b841b5da5 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -127,7 +127,7 @@ export async function agentViaGatewayCommand( sessionId: opts.sessionId, }); - const channel = normalizeProvider(opts.provider) ?? "whatsapp"; + const provider = normalizeProvider(opts.provider) ?? "whatsapp"; const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey(); const response = await callGateway({ @@ -139,7 +139,7 @@ export async function agentViaGatewayCommand( sessionKey, thinking: opts.thinking, deliver: Boolean(opts.deliver), - channel, + provider, timeout: timeoutSeconds, lane: opts.lane, extraSystemPrompt: opts.extraSystemPrompt, diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 4fda21fc2..32e2f0ed5 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -59,7 +59,8 @@ type AgentCommandOpts = { json?: boolean; timeout?: string; deliver?: boolean; - surface?: string; + /** Message provider context (webchat|voicewake|whatsapp|...). */ + messageProvider?: string; provider?: string; // delivery provider (whatsapp|telegram|...) bestEffortDeliver?: boolean; abortSignal?: AbortSignal; @@ -231,7 +232,7 @@ export async function agentCommand( cfg, entry: sessionEntry, sessionKey, - surface: sessionEntry?.surface, + provider: sessionEntry?.provider, chatType: sessionEntry?.chatType, }); if (sendPolicy === "deny") { @@ -379,8 +380,8 @@ export async function agentCommand( let fallbackProvider = provider; let fallbackModel = model; try { - const surface = - opts.surface?.trim().toLowerCase() || + const messageProvider = + opts.messageProvider?.trim().toLowerCase() || (() => { const raw = opts.provider?.trim().toLowerCase(); if (!raw) return undefined; @@ -394,7 +395,7 @@ export async function agentCommand( runEmbeddedPiAgent({ sessionId, sessionKey, - surface, + messageProvider, sessionFile, workspaceDir, config: cfg, diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts new file mode 100644 index 000000000..de8d47750 --- /dev/null +++ b/src/commands/doctor-state-migrations.test.ts @@ -0,0 +1,180 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { + detectLegacyStateMigrations, + runLegacyStateMigrations, +} from "./doctor-state-migrations.js"; + +let tempRoot: string | null = null; + +async function makeTempRoot() { + const root = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "clawdbot-doctor-"), + ); + tempRoot = root; + return root; +} + +afterEach(async () => { + if (!tempRoot) return; + await fs.promises.rm(tempRoot, { recursive: true, force: true }); + tempRoot = null; +}); + +function writeJson5(filePath: string, value: unknown) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8"); +} + +describe("doctor legacy state migrations", () => { + it("migrates legacy sessions into agents//sessions", async () => { + const root = await makeTempRoot(); + const cfg: ClawdbotConfig = {}; + const legacySessionsDir = path.join(root, "sessions"); + fs.mkdirSync(legacySessionsDir, { recursive: true }); + + writeJson5(path.join(legacySessionsDir, "sessions.json"), { + "+1555": { sessionId: "a", updatedAt: 10 }, + "+1666": { sessionId: "b", updatedAt: 20 }, + "slack:channel:C123": { sessionId: "c", updatedAt: 30 }, + "group:abc": { sessionId: "d", updatedAt: 40 }, + "subagent:xyz": { sessionId: "e", updatedAt: 50 }, + }); + fs.writeFileSync(path.join(legacySessionsDir, "a.jsonl"), "a", "utf-8"); + fs.writeFileSync(path.join(legacySessionsDir, "b.jsonl"), "b", "utf-8"); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + const result = await runLegacyStateMigrations({ + detected, + now: () => 123, + }); + + expect(result.warnings).toEqual([]); + const targetDir = path.join(root, "agents", "main", "sessions"); + expect(fs.existsSync(path.join(targetDir, "a.jsonl"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "b.jsonl"))).toBe(true); + expect(fs.existsSync(path.join(legacySessionsDir, "a.jsonl"))).toBe(false); + + const store = JSON.parse( + fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"), + ) as Record; + expect(store["agent:main:main"]?.sessionId).toBe("b"); + expect(store["agent:main:slack:channel:C123"]?.sessionId).toBe("c"); + expect(store["group:abc"]?.sessionId).toBe("d"); + expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e"); + }); + + it("migrates legacy agent dir with conflict fallback", async () => { + const root = await makeTempRoot(); + const cfg: ClawdbotConfig = {}; + + const legacyAgentDir = path.join(root, "agent"); + fs.mkdirSync(legacyAgentDir, { recursive: true }); + fs.writeFileSync(path.join(legacyAgentDir, "foo.txt"), "legacy", "utf-8"); + fs.writeFileSync(path.join(legacyAgentDir, "baz.txt"), "legacy2", "utf-8"); + + const targetAgentDir = path.join(root, "agents", "main", "agent"); + fs.mkdirSync(targetAgentDir, { recursive: true }); + fs.writeFileSync(path.join(targetAgentDir, "foo.txt"), "new", "utf-8"); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + await runLegacyStateMigrations({ detected, now: () => 123 }); + + expect(fs.readFileSync(path.join(targetAgentDir, "baz.txt"), "utf-8")).toBe( + "legacy2", + ); + const backupDir = path.join(root, "agents", "main", "agent.legacy-123"); + expect(fs.existsSync(path.join(backupDir, "foo.txt"))).toBe(true); + }); + + it("migrates legacy WhatsApp auth files without touching oauth.json", async () => { + const root = await makeTempRoot(); + const cfg: ClawdbotConfig = {}; + + const oauthDir = path.join(root, "credentials"); + fs.mkdirSync(oauthDir, { recursive: true }); + fs.writeFileSync(path.join(oauthDir, "oauth.json"), "{}", "utf-8"); + fs.writeFileSync(path.join(oauthDir, "creds.json"), "{}", "utf-8"); + fs.writeFileSync(path.join(oauthDir, "session-abc.json"), "{}", "utf-8"); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + await runLegacyStateMigrations({ detected, now: () => 123 }); + + const target = path.join(oauthDir, "whatsapp", "default"); + expect(fs.existsSync(path.join(target, "creds.json"))).toBe(true); + expect(fs.existsSync(path.join(target, "session-abc.json"))).toBe(true); + expect(fs.existsSync(path.join(oauthDir, "oauth.json"))).toBe(true); + expect(fs.existsSync(path.join(oauthDir, "creds.json"))).toBe(false); + }); + + it("no-ops when nothing detected", async () => { + const root = await makeTempRoot(); + const cfg: ClawdbotConfig = {}; + const detected = await detectLegacyStateMigrations({ + cfg, + env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + const result = await runLegacyStateMigrations({ detected }); + expect(result.changes).toEqual([]); + }); + + it("routes legacy state to routing.defaultAgentId", async () => { + const root = await makeTempRoot(); + const cfg: ClawdbotConfig = { routing: { defaultAgentId: "alpha" } }; + const legacySessionsDir = path.join(root, "sessions"); + fs.mkdirSync(legacySessionsDir, { recursive: true }); + writeJson5(path.join(legacySessionsDir, "sessions.json"), { + "+1555": { sessionId: "a", updatedAt: 10 }, + }); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + await runLegacyStateMigrations({ detected, now: () => 123 }); + + const targetDir = path.join(root, "agents", "alpha", "sessions"); + const store = JSON.parse( + fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"), + ) as Record; + expect(store["agent:alpha:main"]?.sessionId).toBe("a"); + }); + + it("honors session.mainKey when seeding the direct-chat bucket", async () => { + const root = await makeTempRoot(); + const cfg: ClawdbotConfig = { session: { mainKey: "work" } }; + const legacySessionsDir = path.join(root, "sessions"); + fs.mkdirSync(legacySessionsDir, { recursive: true }); + writeJson5(path.join(legacySessionsDir, "sessions.json"), { + "+1555": { sessionId: "a", updatedAt: 10 }, + "+1666": { sessionId: "b", updatedAt: 20 }, + }); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + await runLegacyStateMigrations({ detected, now: () => 123 }); + + const targetDir = path.join(root, "agents", "main", "sessions"); + const store = JSON.parse( + fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"), + ) as Record; + expect(store["agent:main:work"]?.sessionId).toBe("b"); + expect(store["agent:main:main"]).toBeUndefined(); + }); +}); diff --git a/src/commands/doctor-state-migrations.ts b/src/commands/doctor-state-migrations.ts new file mode 100644 index 000000000..b599e8a7f --- /dev/null +++ b/src/commands/doctor-state-migrations.ts @@ -0,0 +1,456 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import JSON5 from "json5"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; +import type { SessionEntry } from "../config/sessions.js"; +import { saveSessionStore } from "../config/sessions.js"; +import { + buildAgentMainSessionKey, + DEFAULT_ACCOUNT_ID, + DEFAULT_AGENT_ID, + DEFAULT_MAIN_KEY, + normalizeAgentId, +} from "../routing/session-key.js"; + +export type LegacyStateDetection = { + targetAgentId: string; + targetMainKey: string; + stateDir: string; + oauthDir: string; + sessions: { + legacyDir: string; + legacyStorePath: string; + targetDir: string; + targetStorePath: string; + hasLegacy: boolean; + }; + agentDir: { + legacyDir: string; + targetDir: string; + hasLegacy: boolean; + }; + whatsappAuth: { + legacyDir: string; + targetDir: string; + hasLegacy: boolean; + }; + preview: string[]; +}; + +type SessionEntryLike = { sessionId?: string; updatedAt?: number } & Record< + string, + unknown +>; + +function safeReadDir(dir: string): fs.Dirent[] { + try { + return fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } +} + +function existsDir(dir: string): boolean { + try { + return fs.existsSync(dir) && fs.statSync(dir).isDirectory(); + } catch { + return false; + } +} + +function ensureDir(dir: string) { + fs.mkdirSync(dir, { recursive: true }); +} + +function fileExists(p: string): boolean { + try { + return fs.existsSync(p) && fs.statSync(p).isFile(); + } catch { + return false; + } +} + +function isLegacyWhatsAppAuthFile(name: string): boolean { + if (name === "creds.json" || name === "creds.json.bak") return true; + if (!name.endsWith(".json")) return false; + return /^(app-state-sync|session|sender-key|pre-key)-/.test(name); +} + +function readSessionStoreJson5(storePath: string): { + store: Record; + ok: boolean; +} { + try { + const raw = fs.readFileSync(storePath, "utf-8"); + const parsed = JSON5.parse(raw); + if (parsed && typeof parsed === "object") { + return { store: parsed as Record, ok: true }; + } + } catch { + // ignore + } + return { store: {}, ok: false }; +} + +function isSurfaceGroupKey(key: string): boolean { + return key.includes(":group:") || key.includes(":channel:"); +} + +function isLegacyGroupKey(key: string): boolean { + return key.startsWith("group:") || key.includes("@g.us"); +} + +function normalizeSessionKeyForAgent(key: string, agentId: string): string { + const raw = key.trim(); + if (!raw) return raw; + if (raw.startsWith("agent:")) return raw; + if (raw.toLowerCase().startsWith("subagent:")) { + const rest = raw.slice("subagent:".length); + return `agent:${normalizeAgentId(agentId)}:subagent:${rest}`; + } + if (isSurfaceGroupKey(raw)) { + return `agent:${normalizeAgentId(agentId)}:${raw}`; + } + return raw; +} + +function pickLatestLegacyDirectEntry( + store: Record, +): SessionEntryLike | null { + let best: SessionEntryLike | null = null; + let bestUpdated = -1; + for (const [key, entry] of Object.entries(store)) { + if (!entry || typeof entry !== "object") continue; + const normalized = key.trim(); + if (!normalized) continue; + if (normalized === "global") continue; + if (normalized.startsWith("agent:")) continue; + if (normalized.toLowerCase().startsWith("subagent:")) continue; + if (isLegacyGroupKey(normalized) || isSurfaceGroupKey(normalized)) continue; + const updatedAt = typeof entry.updatedAt === "number" ? entry.updatedAt : 0; + if (updatedAt > bestUpdated) { + bestUpdated = updatedAt; + best = entry; + } + } + return best; +} + +function normalizeSessionEntry(entry: SessionEntryLike): SessionEntry | null { + const sessionId = + typeof entry.sessionId === "string" ? entry.sessionId : null; + if (!sessionId) return null; + const updatedAt = + typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt) + ? entry.updatedAt + : Date.now(); + return { ...(entry as unknown as SessionEntry), sessionId, updatedAt }; +} + +function emptyDirOrMissing(dir: string): boolean { + if (!existsDir(dir)) return true; + return safeReadDir(dir).length === 0; +} + +function removeDirIfEmpty(dir: string) { + if (!existsDir(dir)) return; + if (!emptyDirOrMissing(dir)) return; + try { + fs.rmdirSync(dir); + } catch { + // ignore + } +} + +export async function detectLegacyStateMigrations(params: { + cfg: ClawdbotConfig; + env?: NodeJS.ProcessEnv; + homedir?: () => string; +}): Promise { + const env = params.env ?? process.env; + const homedir = params.homedir ?? os.homedir; + const stateDir = resolveStateDir(env, homedir); + const oauthDir = resolveOAuthDir(env, stateDir); + + const targetAgentId = normalizeAgentId( + params.cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID, + ); + const rawMainKey = params.cfg.session?.mainKey; + const targetMainKey = + typeof rawMainKey === "string" && rawMainKey.trim().length > 0 + ? rawMainKey.trim() + : DEFAULT_MAIN_KEY; + + const sessionsLegacyDir = path.join(stateDir, "sessions"); + const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json"); + const sessionsTargetDir = path.join( + stateDir, + "agents", + targetAgentId, + "sessions", + ); + const sessionsTargetStorePath = path.join(sessionsTargetDir, "sessions.json"); + const legacySessionEntries = safeReadDir(sessionsLegacyDir); + const hasLegacySessions = + fileExists(sessionsLegacyStorePath) || + legacySessionEntries.some((e) => e.isFile() && e.name.endsWith(".jsonl")); + + const legacyAgentDir = path.join(stateDir, "agent"); + const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent"); + const hasLegacyAgentDir = existsDir(legacyAgentDir); + + const targetWhatsAppAuthDir = path.join( + oauthDir, + "whatsapp", + DEFAULT_ACCOUNT_ID, + ); + const hasLegacyWhatsAppAuth = + fileExists(path.join(oauthDir, "creds.json")) && + !fileExists(path.join(targetWhatsAppAuthDir, "creds.json")); + + const preview: string[] = []; + if (hasLegacySessions) { + preview.push(`- Sessions: ${sessionsLegacyDir} → ${sessionsTargetDir}`); + } + if (hasLegacyAgentDir) { + preview.push(`- Agent dir: ${legacyAgentDir} → ${targetAgentDir}`); + } + if (hasLegacyWhatsAppAuth) { + preview.push( + `- WhatsApp auth: ${oauthDir} → ${targetWhatsAppAuthDir} (keep oauth.json)`, + ); + } + + return { + targetAgentId, + targetMainKey, + stateDir, + oauthDir, + sessions: { + legacyDir: sessionsLegacyDir, + legacyStorePath: sessionsLegacyStorePath, + targetDir: sessionsTargetDir, + targetStorePath: sessionsTargetStorePath, + hasLegacy: hasLegacySessions, + }, + agentDir: { + legacyDir: legacyAgentDir, + targetDir: targetAgentDir, + hasLegacy: hasLegacyAgentDir, + }, + whatsappAuth: { + legacyDir: oauthDir, + targetDir: targetWhatsAppAuthDir, + hasLegacy: hasLegacyWhatsAppAuth, + }, + preview, + }; +} + +async function migrateLegacySessions( + detected: LegacyStateDetection, + now: () => number, +): Promise<{ changes: string[]; warnings: string[] }> { + const changes: string[] = []; + const warnings: string[] = []; + if (!detected.sessions.hasLegacy) return { changes, warnings }; + + ensureDir(detected.sessions.targetDir); + + const legacyParsed = fileExists(detected.sessions.legacyStorePath) + ? readSessionStoreJson5(detected.sessions.legacyStorePath) + : { store: {}, ok: true }; + const targetParsed = fileExists(detected.sessions.targetStorePath) + ? readSessionStoreJson5(detected.sessions.targetStorePath) + : { store: {}, ok: true }; + const legacyStore = legacyParsed.store; + const targetStore = targetParsed.store; + + const normalizedLegacy: Record = {}; + for (const [key, entry] of Object.entries(legacyStore)) { + const nextKey = normalizeSessionKeyForAgent(key, detected.targetAgentId); + if (!nextKey) continue; + if (!normalizedLegacy[nextKey]) normalizedLegacy[nextKey] = entry; + } + + const merged: Record = { + ...normalizedLegacy, + ...targetStore, + }; + + const mainKey = buildAgentMainSessionKey({ + agentId: detected.targetAgentId, + mainKey: detected.targetMainKey, + }); + if (!merged[mainKey]) { + const latest = pickLatestLegacyDirectEntry(legacyStore); + if (latest?.sessionId) { + merged[mainKey] = latest; + changes.push(`Migrated latest direct-chat session → ${mainKey}`); + } + } + + if (!legacyParsed.ok) { + warnings.push( + `Legacy sessions store unreadable; left in place at ${detected.sessions.legacyStorePath}`, + ); + } + + if ( + legacyParsed.ok && + (Object.keys(legacyStore).length > 0 || Object.keys(targetStore).length > 0) + ) { + const normalized: Record = {}; + for (const [key, entry] of Object.entries(merged)) { + const normalizedEntry = normalizeSessionEntry(entry); + if (!normalizedEntry) continue; + normalized[key] = normalizedEntry; + } + await saveSessionStore(detected.sessions.targetStorePath, normalized); + changes.push( + `Merged sessions store → ${detected.sessions.targetStorePath}`, + ); + } + + const entries = safeReadDir(detected.sessions.legacyDir); + for (const entry of entries) { + if (!entry.isFile()) continue; + if (entry.name === "sessions.json") continue; + const from = path.join(detected.sessions.legacyDir, entry.name); + const to = path.join(detected.sessions.targetDir, entry.name); + if (fileExists(to)) continue; + try { + fs.renameSync(from, to); + changes.push( + `Moved ${entry.name} → agents/${detected.targetAgentId}/sessions`, + ); + } catch (err) { + warnings.push(`Failed moving ${from}: ${String(err)}`); + } + } + + if (legacyParsed.ok) { + try { + if (fileExists(detected.sessions.legacyStorePath)) { + fs.rmSync(detected.sessions.legacyStorePath, { force: true }); + } + } catch { + // ignore + } + } + + removeDirIfEmpty(detected.sessions.legacyDir); + const legacyLeft = safeReadDir(detected.sessions.legacyDir).filter((e) => + e.isFile(), + ); + if (legacyLeft.length > 0) { + const backupDir = `${detected.sessions.legacyDir}.legacy-${now()}`; + try { + fs.renameSync(detected.sessions.legacyDir, backupDir); + warnings.push(`Left legacy sessions at ${backupDir}`); + } catch { + // ignore + } + } + + return { changes, warnings }; +} + +async function migrateLegacyAgentDir( + detected: LegacyStateDetection, + now: () => number, +): Promise<{ changes: string[]; warnings: string[] }> { + const changes: string[] = []; + const warnings: string[] = []; + if (!detected.agentDir.hasLegacy) return { changes, warnings }; + + ensureDir(detected.agentDir.targetDir); + + const entries = safeReadDir(detected.agentDir.legacyDir); + for (const entry of entries) { + const from = path.join(detected.agentDir.legacyDir, entry.name); + const to = path.join(detected.agentDir.targetDir, entry.name); + if (fs.existsSync(to)) continue; + try { + fs.renameSync(from, to); + changes.push( + `Moved agent file ${entry.name} → agents/${detected.targetAgentId}/agent`, + ); + } catch (err) { + warnings.push(`Failed moving ${from}: ${String(err)}`); + } + } + + removeDirIfEmpty(detected.agentDir.legacyDir); + if (!emptyDirOrMissing(detected.agentDir.legacyDir)) { + const backupDir = path.join( + detected.stateDir, + "agents", + detected.targetAgentId, + `agent.legacy-${now()}`, + ); + try { + fs.renameSync(detected.agentDir.legacyDir, backupDir); + warnings.push(`Left legacy agent dir at ${backupDir}`); + } catch (err) { + warnings.push(`Failed relocating legacy agent dir: ${String(err)}`); + } + } + + return { changes, warnings }; +} + +async function migrateLegacyWhatsAppAuth( + detected: LegacyStateDetection, +): Promise<{ changes: string[]; warnings: string[] }> { + const changes: string[] = []; + const warnings: string[] = []; + if (!detected.whatsappAuth.hasLegacy) return { changes, warnings }; + + ensureDir(detected.whatsappAuth.targetDir); + + const entries = safeReadDir(detected.whatsappAuth.legacyDir); + for (const entry of entries) { + if (!entry.isFile()) continue; + if (entry.name === "oauth.json") continue; + if (!isLegacyWhatsAppAuthFile(entry.name)) continue; + const from = path.join(detected.whatsappAuth.legacyDir, entry.name); + const to = path.join(detected.whatsappAuth.targetDir, entry.name); + if (fileExists(to)) continue; + try { + fs.renameSync(from, to); + changes.push(`Moved WhatsApp auth ${entry.name} → whatsapp/default`); + } catch (err) { + warnings.push(`Failed moving ${from}: ${String(err)}`); + } + } + + return { changes, warnings }; +} + +export async function runLegacyStateMigrations(params: { + detected: LegacyStateDetection; + now?: () => number; +}): Promise<{ changes: string[]; warnings: string[] }> { + const now = params.now ?? (() => Date.now()); + const detected = params.detected; + const sessions = await migrateLegacySessions(detected, now); + const agentDir = await migrateLegacyAgentDir(detected, now); + const whatsappAuth = await migrateLegacyWhatsAppAuth(detected); + return { + changes: [ + ...sessions.changes, + ...agentDir.changes, + ...whatsappAuth.changes, + ], + warnings: [ + ...sessions.warnings, + ...agentDir.warnings, + ...whatsappAuth.warnings, + ], + }; +} diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 3f2de8508..d0b587653 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -135,6 +135,37 @@ vi.mock("./onboard-helpers.js", () => ({ printWizardHeader: vi.fn(), })); +vi.mock("./doctor-state-migrations.js", () => ({ + detectLegacyStateMigrations: vi.fn().mockResolvedValue({ + targetAgentId: "main", + targetMainKey: "main", + stateDir: "/tmp/state", + oauthDir: "/tmp/oauth", + sessions: { + legacyDir: "/tmp/state/sessions", + legacyStorePath: "/tmp/state/sessions/sessions.json", + targetDir: "/tmp/state/agents/main/sessions", + targetStorePath: "/tmp/state/agents/main/sessions/sessions.json", + hasLegacy: false, + }, + agentDir: { + legacyDir: "/tmp/state/agent", + targetDir: "/tmp/state/agents/main/agent", + hasLegacy: false, + }, + whatsappAuth: { + legacyDir: "/tmp/oauth", + targetDir: "/tmp/oauth/whatsapp/default", + hasLegacy: false, + }, + preview: [], + }), + runLegacyStateMigrations: vi.fn().mockResolvedValue({ + changes: [], + warnings: [], + }), +})); + describe("doctor", () => { it("migrates routing.allowFrom to whatsapp.allowFrom", async () => { readConfigFileSnapshot.mockResolvedValue({ diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index b53e69cfd..0bb984cd9 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -34,6 +34,10 @@ import { defaultRuntime } from "../runtime.js"; import { readTelegramAllowFromStore } from "../telegram/pairing-store.js"; import { resolveTelegramToken } from "../telegram/token.js"; import { normalizeE164, resolveUserPath, sleep } from "../utils.js"; +import { + detectLegacyStateMigrations, + runLegacyStateMigrations, +} from "./doctor-state-migrations.js"; import { healthCommand } from "./health.js"; import { applyWizardMetadata, @@ -834,6 +838,29 @@ export async function doctorCommand( cfg = normalized.config; } + const legacyState = await detectLegacyStateMigrations({ cfg }); + if (legacyState.preview.length > 0) { + note(legacyState.preview.join("\n"), "Legacy state detected"); + const migrate = guardCancel( + await confirm({ + message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?", + initialValue: true, + }), + runtime, + ); + if (migrate) { + const migrated = await runLegacyStateMigrations({ + detected: legacyState, + }); + if (migrated.changes.length > 0) { + note(migrated.changes.join("\n"), "Doctor changes"); + } + if (migrated.warnings.length > 0) { + note(migrated.warnings.join("\n"), "Doctor warnings"); + } + } + } + cfg = await maybeRepairSandboxImages(cfg, runtime); await maybeMigrateLegacyGatewayService(cfg, runtime); diff --git a/src/commands/health.ts b/src/commands/health.ts index 4b5392994..03bbbf062 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -6,6 +6,7 @@ import { info } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { probeTelegram, type TelegramProbe } from "../telegram/probe.js"; import { resolveTelegramToken } from "../telegram/token.js"; +import { resolveWhatsAppAccount } from "../web/accounts.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { getWebAuthAgeMs, @@ -58,8 +59,9 @@ export async function getHealthSnapshot( timeoutMs?: number, ): Promise { const cfg = loadConfig(); - const linked = await webAuthExists(); - const authAgeMs = getWebAuthAgeMs(); + const account = resolveWhatsAppAccount({ cfg }); + const linked = await webAuthExists(account.authDir); + const authAgeMs = getWebAuthAgeMs(account.authDir); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); @@ -128,7 +130,9 @@ export async function healthCommand( : "Web: not linked (run clawdbot login)", ); if (summary.web.linked) { - logWebSelfId(runtime, true); + const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ cfg }); + logWebSelfId(account.authDir, runtime, true); } if (summary.web.connect) { const base = summary.web.connect.ok diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 56e7d10e2..4ac6f032f 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -5,7 +5,7 @@ import type { DmPolicy } from "../config/types.js"; import { loginWeb } from "../provider-web.js"; import type { RuntimeEnv } from "../runtime.js"; import { normalizeE164 } from "../utils.js"; -import { resolveWebAuthDir } from "../web/session.js"; +import { WA_WEB_AUTH_DIR } from "../web/session.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { detectBinary } from "./onboard-helpers.js"; import type { ProviderChoice } from "./onboard-types.js"; @@ -29,7 +29,7 @@ async function pathExists(filePath: string): Promise { } async function detectWhatsAppLinked(): Promise { - const credsPath = path.join(resolveWebAuthDir(), "creds.json"); + const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json"); return await pathExists(credsPath); } @@ -550,7 +550,7 @@ export async function setupProviders( await prompter.note( [ "Scan the QR with WhatsApp on your phone.", - `Credentials are stored under ${resolveWebAuthDir()}/ for future runs.`, + `Credentials are stored under ${WA_WEB_AUTH_DIR}/ for future runs.`, ].join("\n"), "WhatsApp linking", ); diff --git a/src/commands/send.ts b/src/commands/send.ts index 9dda37ea5..8d2e4e7c8 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -14,6 +14,7 @@ export async function sendCommand( dryRun?: boolean; media?: string; gifPlayback?: boolean; + account?: string; }, deps: CliDeps, runtime: RuntimeEnv, @@ -173,6 +174,7 @@ export async function sendCommand( message: opts.message, mediaUrl: opts.media, gifPlayback: opts.gifPlayback, + accountId: opts.account, provider, idempotencyKey: randomIdempotencyKey(), }, diff --git a/src/commands/status.ts b/src/commands/status.ts index e3c50ef3d..96fdcdf7c 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -16,6 +16,7 @@ import { info } from "../globals.js"; import { buildProviderSummary } from "../infra/provider-summary.js"; import { peekSystemEvents } from "../infra/system-events.js"; import type { RuntimeEnv } from "../runtime.js"; +import { resolveWhatsAppAccount } from "../web/accounts.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { getWebAuthAgeMs, @@ -60,8 +61,9 @@ export type StatusSummary = { export async function getStatusSummary(): Promise { const cfg = loadConfig(); - const linked = await webAuthExists(); - const authAgeMs = getWebAuthAgeMs(); + const account = resolveWhatsAppAccount({ cfg }); + const linked = await webAuthExists(account.authDir); + const authAgeMs = getWebAuthAgeMs(account.authDir); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); const providerSummary = await buildProviderSummary(cfg); const queuedSystemEvents = peekSystemEvents(); @@ -230,7 +232,9 @@ export async function statusCommand( `Web session: ${summary.web.linked ? "linked" : "not linked"}${summary.web.linked ? ` (last refreshed ${formatAge(summary.web.authAgeMs)})` : ""}`, ); if (summary.web.linked) { - logWebSelfId(runtime, true); + const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ cfg }); + logWebSelfId(account.authDir, runtime, true); } runtime.log(info("System:")); for (const line of summary.providerSummary) { diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index 4f0337a1a..611d16913 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -1,6 +1,6 @@ import type { ClawdbotConfig } from "./config.js"; -export type GroupPolicySurface = "whatsapp" | "telegram" | "imessage"; +export type GroupPolicyProvider = "whatsapp" | "telegram" | "imessage"; export type ProviderGroupConfig = { requireMention?: boolean; @@ -17,21 +17,21 @@ type ProviderGroups = Record; function resolveProviderGroups( cfg: ClawdbotConfig, - surface: GroupPolicySurface, + provider: GroupPolicyProvider, ): ProviderGroups | undefined { - if (surface === "whatsapp") return cfg.whatsapp?.groups; - if (surface === "telegram") return cfg.telegram?.groups; - if (surface === "imessage") return cfg.imessage?.groups; + if (provider === "whatsapp") return cfg.whatsapp?.groups; + if (provider === "telegram") return cfg.telegram?.groups; + if (provider === "imessage") return cfg.imessage?.groups; return undefined; } export function resolveProviderGroupPolicy(params: { cfg: ClawdbotConfig; - surface: GroupPolicySurface; + provider: GroupPolicyProvider; groupId?: string | null; }): ProviderGroupPolicy { - const { cfg, surface } = params; - const groups = resolveProviderGroups(cfg, surface); + const { cfg, provider } = params; + const groups = resolveProviderGroups(cfg, provider); const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0); const normalizedId = params.groupId?.trim(); const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined; @@ -54,7 +54,7 @@ export function resolveProviderGroupPolicy(params: { export function resolveProviderGroupRequireMention(params: { cfg: ClawdbotConfig; - surface: GroupPolicySurface; + provider: GroupPolicyProvider; groupId?: string | null; requireMentionOverride?: boolean; overrideOrder?: "before-config" | "after-config"; diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 982c6e918..c7529eaf1 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -33,30 +33,30 @@ describe("sessions", () => { ); }); - it("prefixes group keys with surface when available", () => { + it("prefixes group keys with provider when available", () => { expect( deriveSessionKey("per-sender", { From: "12345-678@g.us", ChatType: "group", - Surface: "whatsapp", + Provider: "whatsapp", }), ).toBe("whatsapp:group:12345-678@g.us"); }); - it("keeps explicit surface when provided in group key", () => { + it("keeps explicit provider when provided in group key", () => { expect( resolveSessionKey( "per-sender", { From: "group:discord:12345", ChatType: "group" }, "main", ), - ).toBe("discord:group:12345"); + ).toBe("agent:main:discord:group:12345"); }); it("builds discord display name with guild+channel slugs", () => { expect( buildGroupDisplayName({ - surface: "discord", + provider: "discord", room: "#general", space: "friends-of-clawd", id: "123", @@ -66,22 +66,24 @@ describe("sessions", () => { }); it("collapses direct chats to main by default", () => { - expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("main"); + expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe( + "agent:main:main", + ); }); it("collapses direct chats to main even when sender missing", () => { - expect(resolveSessionKey("per-sender", {})).toBe("main"); + expect(resolveSessionKey("per-sender", {})).toBe("agent:main:main"); }); it("maps direct chats to main key when provided", () => { expect( resolveSessionKey("per-sender", { From: "whatsapp:+1555" }, "main"), - ).toBe("main"); + ).toBe("agent:main:main"); }); it("uses custom main key when provided", () => { expect(resolveSessionKey("per-sender", { From: "+1555" }, "primary")).toBe( - "primary", + "agent:main:primary", ); }); @@ -92,17 +94,18 @@ describe("sessions", () => { it("leaves groups untouched even with main key", () => { expect( resolveSessionKey("per-sender", { From: "12345-678@g.us" }, "main"), - ).toBe("group:12345-678@g.us"); + ).toBe("agent:main:group:12345-678@g.us"); }); - it("updateLastRoute persists channel and target", async () => { + it("updateLastRoute persists provider and target", async () => { + const mainSessionKey = "agent:main:main"; const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-")); const storePath = path.join(dir, "sessions.json"); await fs.writeFile( storePath, JSON.stringify( { - main: { + [mainSessionKey]: { sessionId: "sess-1", updatedAt: 123, systemSent: true, @@ -117,16 +120,16 @@ describe("sessions", () => { await updateLastRoute({ storePath, - sessionKey: "main", - channel: "telegram", + sessionKey: mainSessionKey, + provider: "telegram", to: " 12345 ", }); const store = loadSessionStore(storePath); - expect(store.main?.sessionId).toBe("sess-1"); - expect(store.main?.updatedAt).toBeGreaterThanOrEqual(123); - expect(store.main?.lastChannel).toBe("telegram"); - expect(store.main?.lastTo).toBe("12345"); + expect(store[mainSessionKey]?.sessionId).toBe("sess-1"); + expect(store[mainSessionKey]?.updatedAt).toBeGreaterThanOrEqual(123); + expect(store[mainSessionKey]?.lastProvider).toBe("telegram"); + expect(store[mainSessionKey]?.lastTo).toBe("12345"); }); it("derives session transcripts dir from CLAWDBOT_STATE_DIR", () => { @@ -134,7 +137,7 @@ describe("sessions", () => { { CLAWDBOT_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv, () => "/home/ignored", ); - expect(dir).toBe("/custom/state/sessions"); + expect(dir).toBe("/custom/state/agents/main/sessions"); }); it("falls back to CLAWDIS_STATE_DIR for session transcripts dir", () => { @@ -142,6 +145,6 @@ describe("sessions", () => { { CLAWDIS_STATE_DIR: "/legacy/state" } as NodeJS.ProcessEnv, () => "/home/ignored", ); - expect(dir).toBe("/legacy/state/sessions"); + expect(dir).toBe("/legacy/state/agents/main/sessions"); }); }); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index a92219c40..5b297ec01 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -6,6 +6,13 @@ import path from "node:path"; import type { Skill } from "@mariozechner/pi-coding-agent"; import JSON5 from "json5"; import type { MsgContext } from "../auto-reply/templating.js"; +import { + buildAgentMainSessionKey, + DEFAULT_AGENT_ID, + DEFAULT_MAIN_KEY, + normalizeAgentId, + parseAgentSessionKey, +} from "../routing/session-key.js"; import { normalizeE164 } from "../utils.js"; import { resolveStateDir } from "./paths.js"; @@ -59,11 +66,11 @@ export type SessionEntry = { contextTokens?: number; compactionCount?: number; displayName?: string; - surface?: string; + provider?: string; subject?: string; room?: string; space?: string; - lastChannel?: + lastProvider?: | "whatsapp" | "telegram" | "discord" @@ -72,13 +79,14 @@ export type SessionEntry = { | "imessage" | "webchat"; lastTo?: string; + lastAccountId?: string; skillsSnapshot?: SessionSkillSnapshot; }; export type GroupKeyResolution = { key: string; legacyKey?: string; - surface?: string; + provider?: string; id?: string; chatType?: SessionChatType; }; @@ -89,26 +97,45 @@ export type SessionSkillSnapshot = { resolvedSkills?: Skill[]; }; +function resolveAgentSessionsDir( + agentId?: string, + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir, +): string { + const root = resolveStateDir(env, homedir); + const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID); + return path.join(root, "agents", id, "sessions"); +} + export function resolveSessionTranscriptsDir( env: NodeJS.ProcessEnv = process.env, homedir: () => string = os.homedir, ): string { - return path.join(resolveStateDir(env, homedir), "sessions"); + return resolveAgentSessionsDir(DEFAULT_AGENT_ID, env, homedir); } -export function resolveDefaultSessionStorePath(): string { - return path.join(resolveSessionTranscriptsDir(), "sessions.json"); +export function resolveDefaultSessionStorePath(agentId?: string): string { + return path.join(resolveAgentSessionsDir(agentId), "sessions.json"); } export const DEFAULT_RESET_TRIGGER = "/new"; export const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"]; export const DEFAULT_IDLE_MINUTES = 60; -export function resolveSessionTranscriptPath(sessionId: string): string { - return path.join(resolveSessionTranscriptsDir(), `${sessionId}.jsonl`); +export function resolveSessionTranscriptPath( + sessionId: string, + agentId?: string, +): string { + return path.join(resolveAgentSessionsDir(agentId), `${sessionId}.jsonl`); } -export function resolveStorePath(store?: string) { - if (!store) return resolveDefaultSessionStorePath(); +export function resolveStorePath(store?: string, opts?: { agentId?: string }) { + const agentId = normalizeAgentId(opts?.agentId ?? DEFAULT_AGENT_ID); + if (!store) return resolveDefaultSessionStorePath(agentId); + if (store.includes("{agentId}")) { + return path.resolve( + store.replaceAll("{agentId}", agentId).replace("~", os.homedir()), + ); + } if (store.startsWith("~")) return path.resolve(store.replace("~", os.homedir())); return path.resolve(store); @@ -116,9 +143,32 @@ export function resolveStorePath(store?: string) { export function resolveMainSessionKey(cfg?: { session?: { scope?: SessionScope; mainKey?: string }; + routing?: { defaultAgentId?: string }; }): string { if (cfg?.session?.scope === "global") return "global"; - return "main"; + const agentId = normalizeAgentId( + cfg?.routing?.defaultAgentId ?? DEFAULT_AGENT_ID, + ); + const mainKey = + (cfg?.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY; + return buildAgentMainSessionKey({ agentId, mainKey }); +} + +export function resolveAgentIdFromSessionKey( + sessionKey?: string | null, +): string { + const parsed = parseAgentSessionKey(sessionKey); + return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID); +} + +export function resolveAgentMainSessionKey(params: { + cfg?: { session?: { mainKey?: string } }; + agentId: string; +}): string { + const mainKey = + (params.cfg?.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || + DEFAULT_MAIN_KEY; + return buildAgentMainSessionKey({ agentId: params.agentId, mainKey }); } function normalizeGroupLabel(raw?: string) { @@ -137,14 +187,14 @@ function shortenGroupId(value?: string) { } export function buildGroupDisplayName(params: { - surface?: string; + provider?: string; subject?: string; room?: string; space?: string; id?: string; key: string; }) { - const surfaceKey = (params.surface?.trim().toLowerCase() || "group").trim(); + const providerKey = (params.provider?.trim().toLowerCase() || "group").trim(); const room = params.room?.trim(); const space = params.space?.trim(); const subject = params.subject?.trim(); @@ -169,7 +219,7 @@ export function buildGroupDisplayName(params: { ) { token = `g-${token}`; } - return token ? `${surfaceKey}:${token}` : surfaceKey; + return token ? `${providerKey}:${token}` : providerKey; } export function resolveGroupSessionKey( @@ -186,13 +236,13 @@ export function resolveGroupSessionKey( from.includes(":channel:"); if (!isGroup) return null; - const surfaceHint = ctx.Surface?.trim().toLowerCase(); + const providerHint = ctx.Provider?.trim().toLowerCase(); const hasLegacyGroupPrefix = from.startsWith("group:"); const raw = ( hasLegacyGroupPrefix ? from.slice("group:".length) : from ).trim(); - let surface: string | undefined; + let provider: string | undefined; let kind: "group" | "channel" | undefined; let id = ""; @@ -203,7 +253,7 @@ export function resolveGroupSessionKey( const parseParts = (parts: string[]) => { if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) { - surface = parts[0]; + provider = parts[0]; if (parts.length >= 3) { const kindCandidate = parts[1]; if (["group", "channel"].includes(kindCandidate)) { @@ -239,8 +289,8 @@ export function resolveGroupSessionKey( } } - const resolvedSurface = surface ?? surfaceHint; - if (!resolvedSurface) { + const resolvedProvider = provider ?? providerHint; + if (!resolvedProvider) { const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`; return { key: legacy, @@ -251,7 +301,7 @@ export function resolveGroupSessionKey( } const resolvedKind = kind === "channel" ? "channel" : "group"; - const key = `${resolvedSurface}:${resolvedKind}:${id || raw || from}`; + const key = `${resolvedProvider}:${resolvedKind}:${id || raw || from}`; let legacyKey: string | undefined; if (hasLegacyGroupPrefix || from.includes("@g.us")) { legacyKey = `group:${id || raw || from}`; @@ -260,7 +310,7 @@ export function resolveGroupSessionKey( return { key, legacyKey, - surface: resolvedSurface, + provider: resolvedProvider, id: id || raw || from, chatType: resolvedKind === "channel" ? "room" : "group", }; @@ -323,10 +373,11 @@ export async function saveSessionStore( export async function updateLastRoute(params: { storePath: string; sessionKey: string; - channel: SessionEntry["lastChannel"]; + provider: SessionEntry["lastProvider"]; to?: string; + accountId?: string; }) { - const { storePath, sessionKey, channel, to } = params; + const { storePath, sessionKey, provider, to, accountId } = params; const store = loadSessionStore(storePath); const existing = store[sessionKey]; const now = Date.now(); @@ -349,13 +400,16 @@ export async function updateLastRoute(params: { contextTokens: existing?.contextTokens, displayName: existing?.displayName, chatType: existing?.chatType, - surface: existing?.surface, + provider: existing?.provider, subject: existing?.subject, room: existing?.room, space: existing?.space, skillsSnapshot: existing?.skillsSnapshot, - lastChannel: channel, + lastProvider: provider, lastTo: to?.trim() ? to.trim() : undefined, + lastAccountId: accountId?.trim() + ? accountId.trim() + : existing?.lastAccountId, }; store[sessionKey] = next; await saveSessionStore(storePath, store); @@ -384,12 +438,16 @@ export function resolveSessionKey( if (explicit) return explicit; const raw = deriveSessionKey(scope, ctx); if (scope === "global") return raw; - // Default to a single shared direct-chat session called "main"; groups stay isolated. - const canonical = (mainKey ?? "main").trim() || "main"; + const canonicalMainKey = + (mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY; + const canonical = buildAgentMainSessionKey({ + agentId: DEFAULT_AGENT_ID, + mainKey: canonicalMainKey, + }); const isGroup = raw.startsWith("group:") || raw.includes(":group:") || raw.includes(":channel:"); if (!isGroup) return canonical; - return raw; + return `agent:${DEFAULT_AGENT_ID}:${raw}`; } diff --git a/src/config/types.ts b/src/config/types.ts index 7c6e70f08..4066bf533 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -6,7 +6,7 @@ export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; export type SessionSendPolicyAction = "allow" | "deny"; export type SessionSendPolicyMatch = { - surface?: string; + provider?: string; chatType?: "direct" | "group" | "room"; keyPrefix?: string; }; @@ -178,7 +178,7 @@ export type HookMappingConfig = { messageTemplate?: string; textTemplate?: string; deliver?: boolean; - channel?: + provider?: | "last" | "whatsapp" | "telegram" @@ -506,7 +506,7 @@ export type QueueMode = | "interrupt"; export type QueueDropPolicy = "old" | "new" | "summarize"; -export type QueueModeBySurface = { +export type QueueModeByProvider = { whatsapp?: QueueMode; telegram?: QueueMode; discord?: QueueMode; @@ -552,8 +552,8 @@ export type RoutingConfig = { bindings?: Array<{ agentId: string; match: { - surface: string; - surfaceAccountId?: string; + provider: string; + accountId?: string; peer?: { kind: "dm" | "group" | "channel"; id: string }; guildId?: string; teamId?: string; @@ -561,7 +561,7 @@ export type RoutingConfig = { }>; queue?: { mode?: QueueMode; - bySurface?: QueueModeBySurface; + byProvider?: QueueModeByProvider; debounceMs?: number; cap?: number; drop?: QueueDropPolicy; @@ -902,7 +902,7 @@ export type ClawdbotConfig = { elevated?: { /** Enable or disable elevated mode (default: true). */ enabled?: boolean; - /** Approved senders for /elevated (per-surface allowlists). */ + /** Approved senders for /elevated (per-provider allowlists). */ allowFrom?: AgentElevatedAllowFromConfig; }; /** Optional sandbox settings for non-main sessions. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 061ff6445..ce87b9362 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -130,7 +130,7 @@ const SessionSchema = z action: z.union([z.literal("allow"), z.literal("deny")]), match: z .object({ - surface: z.string().optional(), + provider: z.string().optional(), chatType: z .union([ z.literal("direct"), @@ -240,8 +240,8 @@ const RoutingSchema = z z.object({ agentId: z.string(), match: z.object({ - surface: z.string(), - surfaceAccountId: z.string().optional(), + provider: z.string(), + accountId: z.string().optional(), peer: z .object({ kind: z.union([ @@ -261,7 +261,7 @@ const RoutingSchema = z queue: z .object({ mode: QueueModeSchema.optional(), - bySurface: QueueModeBySurfaceSchema, + byProvider: QueueModeBySurfaceSchema, debounceMs: z.number().int().nonnegative().optional(), cap: z.number().int().positive().optional(), drop: QueueDropSchema.optional(), @@ -288,7 +288,7 @@ const HookMappingSchema = z messageTemplate: z.string().optional(), textTemplate: z.string().optional(), deliver: z.boolean().optional(), - channel: z + provider: z .union([ z.literal("last"), z.literal("whatsapp"), diff --git a/src/cron/cron-protocol-conformance.test.ts b/src/cron/cron-protocol-conformance.test.ts index f7b57c551..00e4c2aaf 100644 --- a/src/cron/cron-protocol-conformance.test.ts +++ b/src/cron/cron-protocol-conformance.test.ts @@ -9,22 +9,22 @@ type SchemaLike = { const?: unknown; }; -type ChannelSchema = { +type ProviderSchema = { anyOf?: Array<{ const?: unknown }>; }; -function extractCronChannels(schema: SchemaLike): string[] { +function extractCronProviders(schema: SchemaLike): string[] { const union = schema.anyOf ?? []; - const payloadWithChannel = union.find((entry) => - Boolean(entry?.properties && "channel" in entry.properties), + const payloadWithProvider = union.find((entry) => + Boolean(entry?.properties && "provider" in entry.properties), ); - const channelSchema = payloadWithChannel?.properties - ? (payloadWithChannel.properties.channel as ChannelSchema) + const providerSchema = payloadWithProvider?.properties + ? (payloadWithProvider.properties.provider as ProviderSchema) : undefined; - const channels = (channelSchema?.anyOf ?? []) + const providers = (providerSchema?.anyOf ?? []) .map((entry) => entry?.const) .filter((value): value is string => typeof value === "string"); - return channels; + return providers; } const UI_FILES = [ @@ -36,28 +36,28 @@ const UI_FILES = [ const SWIFT_FILES = ["apps/macos/Sources/Clawdbot/GatewayConnection.swift"]; describe("cron protocol conformance", () => { - it("ui + swift include all cron channels from gateway schema", async () => { - const channels = extractCronChannels(CronPayloadSchema as SchemaLike); - expect(channels.length).toBeGreaterThan(0); + it("ui + swift include all cron providers from gateway schema", async () => { + const providers = extractCronProviders(CronPayloadSchema as SchemaLike); + expect(providers.length).toBeGreaterThan(0); const cwd = process.cwd(); for (const relPath of UI_FILES) { const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); - for (const channel of channels) { + for (const provider of providers) { expect( - content.includes(`"${channel}"`), - `${relPath} missing ${channel}`, + content.includes(`"${provider}"`), + `${relPath} missing ${provider}`, ).toBe(true); } } for (const relPath of SWIFT_FILES) { const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); - for (const channel of channels) { - const pattern = new RegExp(`\\bcase\\s+${channel}\\b`); + for (const provider of providers) { + const pattern = new RegExp(`\\bcase\\s+${provider}\\b`); expect( pattern.test(content), - `${relPath} missing case ${channel}`, + `${relPath} missing case ${provider}`, ).toBe(true); } } diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index 23b29e6b2..85afb10e8 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -42,10 +42,10 @@ async function writeSessionStore(home: string) { storePath, JSON.stringify( { - main: { + "agent:main:main": { sessionId: "main-session", updatedAt: Date.now(), - lastChannel: "webchat", + lastProvider: "webchat", lastTo: "", }, }, @@ -224,7 +224,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "whatsapp", + provider: "whatsapp", bestEffortDeliver: false, }), message: "do it", @@ -264,7 +264,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "whatsapp", + provider: "whatsapp", bestEffortDeliver: true, }), message: "do it", @@ -309,7 +309,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "telegram", + provider: "telegram", to: "123", }), message: "do it", @@ -361,7 +361,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "discord", + provider: "discord", to: "channel:1122", }), message: "do it", @@ -406,7 +406,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "telegram", + provider: "telegram", to: "123", }), message: "do it", @@ -450,7 +450,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "whatsapp", + provider: "whatsapp", to: "+1234", }), message: "do it", @@ -493,7 +493,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "telegram", + provider: "telegram", to: "123", }), message: "do it", @@ -537,7 +537,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "telegram", + provider: "telegram", to: "123", }), message: "do it", @@ -585,7 +585,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "telegram", + provider: "telegram", to: "123", }), message: "do it", diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 03bc20edc..bd495b395 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -29,6 +29,8 @@ import type { ClawdbotConfig } from "../config/config.js"; import { DEFAULT_IDLE_MINUTES, loadSessionStore, + resolveAgentIdFromSessionKey, + resolveMainSessionKey, resolveSessionTranscriptPath, resolveStorePath, type SessionEntry, @@ -87,7 +89,7 @@ function isHeartbeatOnlyResponse( function resolveDeliveryTarget( cfg: ClawdbotConfig, jobPayload: { - channel?: + provider?: | "last" | "whatsapp" | "telegram" @@ -98,36 +100,37 @@ function resolveDeliveryTarget( to?: string; }, ) { - const requestedChannel = - typeof jobPayload.channel === "string" ? jobPayload.channel : "last"; + const requestedProvider = + typeof jobPayload.provider === "string" ? jobPayload.provider : "last"; const explicitTo = typeof jobPayload.to === "string" && jobPayload.to.trim() ? jobPayload.to.trim() : undefined; const sessionCfg = cfg.session; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const storePath = resolveStorePath(sessionCfg?.store); + const mainSessionKey = resolveMainSessionKey(cfg); + const agentId = resolveAgentIdFromSessionKey(mainSessionKey); + const storePath = resolveStorePath(sessionCfg?.store, { agentId }); const store = loadSessionStore(storePath); - const main = store[mainKey]; - const lastChannel = - main?.lastChannel && main.lastChannel !== "webchat" - ? main.lastChannel + const main = store[mainSessionKey]; + const lastProvider = + main?.lastProvider && main.lastProvider !== "webchat" + ? main.lastProvider : undefined; const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : ""; - const channel = (() => { + const provider = (() => { if ( - requestedChannel === "whatsapp" || - requestedChannel === "telegram" || - requestedChannel === "discord" || - requestedChannel === "slack" || - requestedChannel === "signal" || - requestedChannel === "imessage" + requestedProvider === "whatsapp" || + requestedProvider === "telegram" || + requestedProvider === "discord" || + requestedProvider === "slack" || + requestedProvider === "signal" || + requestedProvider === "imessage" ) { - return requestedChannel; + return requestedProvider; } - return lastChannel ?? "whatsapp"; + return lastProvider ?? "whatsapp"; })(); const to = (() => { @@ -136,7 +139,7 @@ function resolveDeliveryTarget( })(); const sanitizedWhatsappTo = (() => { - if (channel !== "whatsapp") return to; + if (provider !== "whatsapp") return to; const rawAllow = cfg.whatsapp?.allowFrom ?? []; if (rawAllow.includes("*")) return to; const allowFrom = rawAllow @@ -150,8 +153,8 @@ function resolveDeliveryTarget( })(); return { - channel, - to: channel === "whatsapp" ? sanitizedWhatsappTo : to, + provider, + to: provider === "whatsapp" ? sanitizedWhatsappTo : to, }; } @@ -181,7 +184,7 @@ function resolveCronSession(params: { model: entry?.model, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, }; return { storePath, store, sessionEntry, systemSent, isNewSession: !fresh }; @@ -251,9 +254,9 @@ export async function runCronIsolatedAgentTurn(params: { params.job.payload.bestEffortDeliver === true; const resolvedDelivery = resolveDeliveryTarget(params.cfg, { - channel: + provider: params.job.payload.kind === "agentTurn" - ? params.job.payload.channel + ? params.job.payload.provider : "last", to: params.job.payload.kind === "agentTurn" @@ -302,7 +305,7 @@ export async function runCronIsolatedAgentTurn(params: { registerAgentRunContext(cronSession.sessionEntry.sessionId, { sessionKey: params.sessionKey, }); - const surface = resolvedDelivery.channel; + const messageProvider = resolvedDelivery.provider; const fallbackResult = await runWithModelFallback({ cfg: params.cfg, provider, @@ -311,7 +314,7 @@ export async function runCronIsolatedAgentTurn(params: { runEmbeddedPiAgent({ sessionId: cronSession.sessionEntry.sessionId, sessionKey: params.sessionKey, - surface, + messageProvider, sessionFile, workspaceDir, config: params.cfg, @@ -380,7 +383,7 @@ export async function runCronIsolatedAgentTurn(params: { delivery && isHeartbeatOnlyResponse(payloads, Math.max(0, ackMaxChars)); if (delivery && !skipHeartbeatDelivery) { - if (resolvedDelivery.channel === "whatsapp") { + if (resolvedDelivery.provider === "whatsapp") { if (!resolvedDelivery.to) { if (!bestEffortDeliver) return { @@ -415,7 +418,7 @@ export async function runCronIsolatedAgentTurn(params: { return { status: "error", summary, error: String(err) }; return { status: "ok", summary }; } - } else if (resolvedDelivery.channel === "telegram") { + } else if (resolvedDelivery.provider === "telegram") { if (!resolvedDelivery.to) { if (!bestEffortDeliver) return { @@ -459,14 +462,14 @@ export async function runCronIsolatedAgentTurn(params: { return { status: "error", summary, error: String(err) }; return { status: "ok", summary }; } - } else if (resolvedDelivery.channel === "discord") { + } else if (resolvedDelivery.provider === "discord") { if (!resolvedDelivery.to) { if (!bestEffortDeliver) return { status: "error", summary, error: - "Cron delivery to Discord requires --channel discord and --to ", + "Cron delivery to Discord requires --provider discord and --to ", }; return { status: "skipped", @@ -503,14 +506,14 @@ export async function runCronIsolatedAgentTurn(params: { return { status: "error", summary, error: String(err) }; return { status: "ok", summary }; } - } else if (resolvedDelivery.channel === "slack") { + } else if (resolvedDelivery.provider === "slack") { if (!resolvedDelivery.to) { if (!bestEffortDeliver) return { status: "error", summary, error: - "Cron delivery to Slack requires --channel slack and --to ", + "Cron delivery to Slack requires --provider slack and --to ", }; return { status: "skipped", @@ -543,7 +546,7 @@ export async function runCronIsolatedAgentTurn(params: { return { status: "error", summary, error: String(err) }; return { status: "ok", summary }; } - } else if (resolvedDelivery.channel === "signal") { + } else if (resolvedDelivery.provider === "signal") { if (!resolvedDelivery.to) { if (!bestEffortDeliver) return { @@ -582,7 +585,7 @@ export async function runCronIsolatedAgentTurn(params: { return { status: "error", summary, error: String(err) }; return { status: "ok", summary }; } - } else if (resolvedDelivery.channel === "imessage") { + } else if (resolvedDelivery.provider === "imessage") { if (!resolvedDelivery.to) { if (!bestEffortDeliver) return { diff --git a/src/cron/types.ts b/src/cron/types.ts index a01479b8a..0ac709f8f 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -14,7 +14,7 @@ export type CronPayload = thinking?: string; timeoutSeconds?: number; deliver?: boolean; - channel?: + provider?: | "last" | "whatsapp" | "telegram" diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index f89ed13be..8ace08a36 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -31,11 +31,7 @@ import type { ReplyToMode, } from "../config/config.js"; import { loadConfig } from "../config/config.js"; -import { - resolveSessionKey, - resolveStorePath, - updateLastRoute, -} from "../config/sessions.js"; +import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { getChildLogger } from "../logging.js"; @@ -45,6 +41,7 @@ import { readProviderAllowFromStore, upsertProviderPairingRequest, } from "../pairing/pairing-store.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { sendMessageDiscord } from "./send.js"; import { normalizeDiscordToken } from "./token.js"; @@ -451,24 +448,20 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } } + const route = resolveAgentRoute({ + cfg, + provider: "discord", + guildId: message.guildId ?? undefined, + peer: { + kind: isDirectMessage ? "dm" : "channel", + id: isDirectMessage ? message.author.id : message.channelId, + }, + }); + const systemText = resolveDiscordSystemEvent(message); if (systemText) { - const sessionCfg = cfg.session; - const sessionScope = sessionCfg?.scope ?? "per-sender"; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const sessionKey = resolveSessionKey( - sessionScope, - { - From: isDirectMessage - ? `discord:${message.author.id}` - : `group:${message.channelId}`, - ChatType: isDirectMessage ? "direct" : "group", - Surface: "discord", - }, - mainKey, - ); enqueueSystemEvent(systemText, { - sessionKey, + sessionKey: route.sessionKey, contextKey: `discord:system:${message.channelId}:${message.id}`, }); return; @@ -514,7 +507,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const groupSubject = isDirectMessage ? undefined : groupRoom; const messageText = text; let combinedBody = formatAgentEnvelope({ - surface: "Discord", + provider: "Discord", from: fromLabel, timestamp: message.createdTimestamp, body: messageText, @@ -529,7 +522,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const historyText = historyWithoutCurrent .map((entry) => formatAgentEnvelope({ - surface: "Discord", + provider: "Discord", from: fromLabel, timestamp: entry.timestamp, body: `${entry.sender}: ${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`, @@ -573,7 +566,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { ? `${snapshotText}\n[${forwardMetaParts.join(" ")}]` : snapshotText; const forwardedEnvelope = formatAgentEnvelope({ - surface: "Discord", + provider: "Discord", from: `Forwarded by ${forwarder}`, timestamp: forwardedSnapshot.snapshot.createdTimestamp ?? @@ -590,6 +583,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { ? `discord:${message.author.id}` : `group:${message.channelId}`, To: `channel:${message.channelId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, ChatType: isDirectMessage ? "direct" : "group", SenderName: message.member?.displayName ?? message.author.tag, SenderId: message.author.id, @@ -600,7 +595,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined, - Surface: "discord" as const, + Provider: "discord" as const, WasMentioned: wasMentioned, MessageSid: message.id, Timestamp: message.createdTimestamp, @@ -617,13 +612,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { if (isDirectMessage) { const sessionCfg = cfg.session; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = resolveStorePath(sessionCfg?.store, { + agentId: route.agentId, + }); await updateLastRoute({ storePath, - sessionKey: mainKey, - channel: "discord", + sessionKey: route.mainSessionKey, + provider: "discord", to: `user:${message.author.id}`, + accountId: route.accountId, }); } @@ -766,20 +763,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const authorLabel = message.author?.tag ?? message.author?.username; const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${message.id}`; const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; - const sessionCfg = cfg.session; - const sessionScope = sessionCfg?.scope ?? "per-sender"; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const sessionKey = resolveSessionKey( - sessionScope, - { - From: `group:${message.channelId}`, - ChatType: "group", - Surface: "discord", - }, - mainKey, - ); + const route = resolveAgentRoute({ + cfg, + provider: "discord", + guildId: guild.id, + peer: { kind: "channel", id: message.channelId }, + }); enqueueSystemEvent(text, { - sessionKey, + sessionKey: route.sessionKey, contextKey: `discord:reaction:${action}:${message.id}:${user.id}:${emojiLabel}`, }); } catch (err) { @@ -884,7 +875,7 @@ async function resolveReplyContext(message: Message): Promise { : (referenced.member?.displayName ?? referenced.author.tag); const body = `${referencedText}\n[discord message id: ${referenced.id} channel: ${referenced.channelId} from: ${referenced.author.tag} user id:${referenced.author.id}]`; return formatAgentEnvelope({ - surface: "Discord", + provider: "Discord", from: fromLabel, timestamp: referenced.createdTimestamp, body, diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 1146828b5..eab89c6d3 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -18,7 +18,7 @@ export type HookMappingResolved = { messageTemplate?: string; textTemplate?: string; deliver?: boolean; - channel?: + provider?: | "last" | "whatsapp" | "telegram" @@ -57,7 +57,7 @@ export type HookAction = wakeMode: "now" | "next-heartbeat"; sessionKey?: string; deliver?: boolean; - channel?: + provider?: | "last" | "whatsapp" | "telegram" @@ -101,7 +101,7 @@ type HookTransformResult = Partial<{ name: string; sessionKey: string; deliver: boolean; - channel: + provider: | "last" | "whatsapp" | "telegram" @@ -196,7 +196,7 @@ function normalizeHookMapping( messageTemplate: mapping.messageTemplate, textTemplate: mapping.textTemplate, deliver: mapping.deliver, - channel: mapping.channel, + provider: mapping.provider, to: mapping.to, thinking: mapping.thinking, timeoutSeconds: mapping.timeoutSeconds, @@ -241,7 +241,7 @@ function buildActionFromMapping( wakeMode: mapping.wakeMode ?? "now", sessionKey: renderOptional(mapping.sessionKey, ctx), deliver: mapping.deliver, - channel: mapping.channel, + provider: mapping.provider, to: renderOptional(mapping.to, ctx), thinking: renderOptional(mapping.thinking, ctx), timeoutSeconds: mapping.timeoutSeconds, @@ -291,7 +291,7 @@ function mergeAction( typeof override.deliver === "boolean" ? override.deliver : baseAgent?.deliver, - channel: override.channel ?? baseAgent?.channel, + provider: override.provider ?? baseAgent?.provider, to: override.to ?? baseAgent?.to, thinking: override.thinking ?? baseAgent?.thinking, timeoutSeconds: override.timeoutSeconds ?? baseAgent?.timeoutSeconds, diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index 1537c5ba5..09f9e66a3 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -56,7 +56,7 @@ describe("gateway hooks helpers", () => { expect(normalizeWakePayload({ text: " ", mode: "now" }).ok).toBe(false); }); - test("normalizeAgentPayload defaults + validates channel", () => { + test("normalizeAgentPayload defaults + validates provider", () => { const ok = normalizeAgentPayload( { message: "hello" }, { idFactory: () => "fixed" }, @@ -64,20 +64,20 @@ describe("gateway hooks helpers", () => { expect(ok.ok).toBe(true); if (ok.ok) { expect(ok.value.sessionKey).toBe("hook:fixed"); - expect(ok.value.channel).toBe("last"); + expect(ok.value.provider).toBe("last"); expect(ok.value.name).toBe("Hook"); } const imsg = normalizeAgentPayload( - { message: "yo", channel: "imsg" }, + { message: "yo", provider: "imsg" }, { idFactory: () => "x" }, ); expect(imsg.ok).toBe(true); if (imsg.ok) { - expect(imsg.value.channel).toBe("imessage"); + expect(imsg.value.provider).toBe("imessage"); } - const bad = normalizeAgentPayload({ message: "yo", channel: "sms" }); + const bad = normalizeAgentPayload({ message: "yo", provider: "sms" }); expect(bad.ok).toBe(false); }); }); diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index 160695ee3..0cfc5490b 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -137,7 +137,7 @@ export type HookAgentPayload = { wakeMode: "now" | "next-heartbeat"; sessionKey: string; deliver: boolean; - channel: + provider: | "last" | "whatsapp" | "telegram" @@ -173,26 +173,26 @@ export function normalizeAgentPayload( typeof sessionKeyRaw === "string" && sessionKeyRaw.trim() ? sessionKeyRaw.trim() : `hook:${idFactory()}`; - const channelRaw = payload.channel; - const channel = - channelRaw === "whatsapp" || - channelRaw === "telegram" || - channelRaw === "discord" || - channelRaw === "slack" || - channelRaw === "signal" || - channelRaw === "imessage" || - channelRaw === "last" - ? channelRaw - : channelRaw === "imsg" + const providerRaw = payload.provider; + const provider = + providerRaw === "whatsapp" || + providerRaw === "telegram" || + providerRaw === "discord" || + providerRaw === "slack" || + providerRaw === "signal" || + providerRaw === "imessage" || + providerRaw === "last" + ? providerRaw + : providerRaw === "imsg" ? "imessage" - : channelRaw === undefined + : providerRaw === undefined ? "last" : null; - if (channel === null) { + if (provider === null) { return { ok: false, error: - "channel must be last|whatsapp|telegram|discord|slack|signal|imessage", + "provider must be last|whatsapp|telegram|discord|slack|signal|imessage", }; } const toRaw = payload.to; @@ -219,7 +219,7 @@ export function normalizeAgentPayload( wakeMode, sessionKey, deliver, - channel, + provider, to, thinking, timeoutSeconds, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index eec93fe79..f58652eb2 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -193,6 +193,7 @@ export const SendParamsSchema = Type.Object( mediaUrl: Type.Optional(Type.String()), gifPlayback: Type.Optional(Type.Boolean()), provider: Type.Optional(Type.String()), + accountId: Type.Optional(Type.String()), idempotencyKey: NonEmptyString, }, { additionalProperties: false }, @@ -206,6 +207,7 @@ export const PollParamsSchema = Type.Object( maxSelections: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })), durationHours: Type.Optional(Type.Integer({ minimum: 1 })), provider: Type.Optional(Type.String()), + accountId: Type.Optional(Type.String()), idempotencyKey: NonEmptyString, }, { additionalProperties: false }, @@ -218,7 +220,7 @@ export const AgentParamsSchema = Type.Object( sessionKey: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), deliver: Type.Optional(Type.Boolean()), - channel: Type.Optional(Type.String()), + provider: Type.Optional(Type.String()), timeout: Type.Optional(Type.Integer({ minimum: 0 })), lane: Type.Optional(Type.String()), extraSystemPrompt: Type.Optional(Type.String()), @@ -543,6 +545,7 @@ export const WebLoginStartParamsSchema = Type.Object( force: Type.Optional(Type.Boolean()), timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), verbose: Type.Optional(Type.Boolean()), + accountId: Type.Optional(Type.String()), }, { additionalProperties: false }, ); @@ -550,6 +553,7 @@ export const WebLoginStartParamsSchema = Type.Object( export const WebLoginWaitParamsSchema = Type.Object( { timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), + accountId: Type.Optional(Type.String()), }, { additionalProperties: false }, ); @@ -642,7 +646,7 @@ export const CronPayloadSchema = Type.Union([ thinking: Type.Optional(Type.String()), timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })), deliver: Type.Optional(Type.Boolean()), - channel: Type.Optional( + provider: Type.Optional( Type.Union([ Type.Literal("last"), Type.Literal("whatsapp"), diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index a0be80f91..c062bfea1 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -48,6 +48,7 @@ import { setVoiceWakeTriggers, } from "../infra/voicewake.js"; import { clearCommandLane } from "../process/command-queue.js"; +import { isSubagentSessionKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; import { normalizeSendPolicy } from "../sessions/send-policy.js"; import { buildMessageWithAttachments } from "./chat-attachments.js"; @@ -372,7 +373,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { }, }; } - if (!key.startsWith("subagent:")) { + if (!isSubagentSessionKey(key)) { return { ok: false, error: { @@ -606,11 +607,11 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { sendPolicy: entry?.sendPolicy, displayName: entry?.displayName, chatType: entry?.chatType, - surface: entry?.surface, + provider: entry?.provider, subject: entry?.subject, room: entry?.room, space: entry?.space, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, skillsSnapshot: entry?.skillsSnapshot, }; @@ -986,7 +987,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, systemSent: entry?.systemSent, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, }; const clientRunId = p.idempotencyKey; @@ -1033,7 +1034,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { thinking: p.thinking, deliver: p.deliver, timeout: Math.ceil(timeoutMs / 1000).toString(), - surface: `Node(${nodeId})`, + messageProvider: `node(${nodeId})`, abortSignal: abortController.signal, }, defaultRuntime, @@ -1126,7 +1127,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { verboseLevel: entry?.verboseLevel, systemSent: entry?.systemSent, sendPolicy: entry?.sendPolicy, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, }; if (storePath) { @@ -1146,7 +1147,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { sessionId, thinking: "low", deliver: false, - surface: "Node", + messageProvider: "node", }, defaultRuntime, ctx.deps, @@ -1208,7 +1209,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { verboseLevel: entry?.verboseLevel, systemSent: entry?.systemSent, sendPolicy: entry?.sendPolicy, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, }; if (storePath) { @@ -1227,7 +1228,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { typeof link?.timeoutSeconds === "number" ? link.timeoutSeconds.toString() : undefined, - surface: "Node", + messageProvider: "node", }, defaultRuntime, ctx.deps, diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 69ac71516..e3524391c 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -32,7 +32,7 @@ type HookDispatchers = { wakeMode: "now" | "next-heartbeat"; sessionKey: string; deliver: boolean; - channel: + provider: | "last" | "whatsapp" | "telegram" @@ -175,7 +175,7 @@ export function createHooksRequestHandler( wakeMode: mapped.action.wakeMode, sessionKey: mapped.action.sessionKey ?? "", deliver: mapped.action.deliver === true, - channel: mapped.action.channel ?? "last", + provider: mapped.action.provider ?? "last", to: mapped.action.to, thinking: mapped.action.thinking, timeoutSeconds: mapped.action.timeoutSeconds, diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index f9497f025..432b8336e 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -2,7 +2,12 @@ import { randomUUID } from "node:crypto"; import { agentCommand } from "../../commands/agent.js"; import { loadConfig } from "../../config/config.js"; -import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; +import { + resolveAgentIdFromSessionKey, + resolveAgentMainSessionKey, + type SessionEntry, + saveSessionStore, +} from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; @@ -41,7 +46,7 @@ export const agentHandlers: GatewayRequestHandlers = { sessionKey?: string; thinking?: string; deliver?: boolean; - channel?: string; + provider?: string; lane?: string; extraSystemPrompt?: string; idempotencyKey: string; @@ -72,7 +77,7 @@ export const agentHandlers: GatewayRequestHandlers = { cfgForAgent = cfg; const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); - sessionEntry = { + const nextEntry: SessionEntry = { sessionId, updatedAt: now, thinkingLevel: entry?.thinkingLevel, @@ -80,14 +85,15 @@ export const agentHandlers: GatewayRequestHandlers = { systemSent: entry?.systemSent, sendPolicy: entry?.sendPolicy, skillsSnapshot: entry?.skillsSnapshot, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, }; + sessionEntry = nextEntry; const sendPolicy = resolveSendPolicy({ cfg, entry, sessionKey: requestedSessionKey, - surface: entry?.surface, + provider: entry?.provider, chatType: entry?.chatType, }); if (sendPolicy === "deny") { @@ -102,14 +108,22 @@ export const agentHandlers: GatewayRequestHandlers = { return; } if (store) { - store[requestedSessionKey] = sessionEntry; + store[requestedSessionKey] = nextEntry; if (storePath) { await saveSessionStore(storePath, store); } } resolvedSessionId = sessionId; - const mainKey = (cfg.session?.mainKey ?? "main").trim() || "main"; - if (requestedSessionKey === mainKey) { + const agentId = resolveAgentIdFromSessionKey(requestedSessionKey); + const mainSessionKey = resolveAgentMainSessionKey({ + cfg, + agentId, + }); + const rawMainKey = (cfg.session?.mainKey ?? "main").trim() || "main"; + if ( + requestedSessionKey === mainSessionKey || + requestedSessionKey === rawMainKey + ) { context.addChatRun(idem, { sessionKey: requestedSessionKey, clientRunId: idem, @@ -121,42 +135,42 @@ export const agentHandlers: GatewayRequestHandlers = { const runId = idem; - const requestedChannelRaw = - typeof request.channel === "string" ? request.channel.trim() : ""; - const requestedChannelNormalized = requestedChannelRaw - ? requestedChannelRaw.toLowerCase() + const requestedProviderRaw = + typeof request.provider === "string" ? request.provider.trim() : ""; + const requestedProviderNormalized = requestedProviderRaw + ? requestedProviderRaw.toLowerCase() : "last"; - const requestedChannel = - requestedChannelNormalized === "imsg" + const requestedProvider = + requestedProviderNormalized === "imsg" ? "imessage" - : requestedChannelNormalized; + : requestedProviderNormalized; - const lastChannel = sessionEntry?.lastChannel; + const lastProvider = sessionEntry?.lastProvider; const lastTo = typeof sessionEntry?.lastTo === "string" ? sessionEntry.lastTo.trim() : ""; - const resolvedChannel = (() => { - if (requestedChannel === "last") { + const resolvedProvider = (() => { + if (requestedProvider === "last") { // WebChat is not a deliverable surface. Treat it as "unset" for routing, // so VoiceWake and CLI callers don't get stuck with deliver=false. - return lastChannel && lastChannel !== "webchat" - ? lastChannel + return lastProvider && lastProvider !== "webchat" + ? lastProvider : "whatsapp"; } if ( - requestedChannel === "whatsapp" || - requestedChannel === "telegram" || - requestedChannel === "discord" || - requestedChannel === "signal" || - requestedChannel === "imessage" || - requestedChannel === "webchat" + requestedProvider === "whatsapp" || + requestedProvider === "telegram" || + requestedProvider === "discord" || + requestedProvider === "signal" || + requestedProvider === "imessage" || + requestedProvider === "webchat" ) { - return requestedChannel; + return requestedProvider; } - return lastChannel && lastChannel !== "webchat" - ? lastChannel + return lastProvider && lastProvider !== "webchat" + ? lastProvider : "whatsapp"; })(); @@ -167,11 +181,11 @@ export const agentHandlers: GatewayRequestHandlers = { : undefined; if (explicit) return explicit; if ( - resolvedChannel === "whatsapp" || - resolvedChannel === "telegram" || - resolvedChannel === "discord" || - resolvedChannel === "signal" || - resolvedChannel === "imessage" + resolvedProvider === "whatsapp" || + resolvedProvider === "telegram" || + resolvedProvider === "discord" || + resolvedProvider === "signal" || + resolvedProvider === "imessage" ) { return lastTo || undefined; } @@ -182,7 +196,7 @@ export const agentHandlers: GatewayRequestHandlers = { // If we derived a WhatsApp recipient from session "lastTo", ensure it is still valid // for the configured allowlist. Otherwise, fall back to the first allowed number so // voice wake doesn't silently route to stale/test recipients. - if (resolvedChannel !== "whatsapp") return resolvedTo; + if (resolvedProvider !== "whatsapp") return resolvedTo; const explicit = typeof request.to === "string" && request.to.trim() ? request.to.trim() @@ -207,7 +221,7 @@ export const agentHandlers: GatewayRequestHandlers = { return allowFrom[0]; })(); - const deliver = request.deliver === true && resolvedChannel !== "webchat"; + const deliver = request.deliver === true && resolvedProvider !== "webchat"; const accepted = { runId, @@ -229,10 +243,10 @@ export const agentHandlers: GatewayRequestHandlers = { sessionId: resolvedSessionId, thinking: request.thinking, deliver, - provider: resolvedChannel, + provider: resolvedProvider, timeout: request.timeout?.toString(), bestEffortDeliver, - surface: "VoiceWake", + messageProvider: "voicewake", runId, lane: request.lane, extraSystemPrompt: request.extraSystemPrompt, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 9d687de53..05104323e 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -202,7 +202,7 @@ export const chatHandlers: GatewayRequestHandlers = { verboseLevel: entry?.verboseLevel, systemSent: entry?.systemSent, sendPolicy: entry?.sendPolicy, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, }; const clientRunId = p.idempotencyKey; @@ -212,7 +212,7 @@ export const chatHandlers: GatewayRequestHandlers = { cfg, entry, sessionKey: p.sessionKey, - surface: entry?.surface, + provider: entry?.provider, chatType: entry?.chatType, }); if (sendPolicy === "deny") { @@ -262,7 +262,7 @@ export const chatHandlers: GatewayRequestHandlers = { thinking: p.thinking, deliver: p.deliver, timeout: Math.ceil(timeoutMs / 1000).toString(), - surface: "WebChat", + messageProvider: "webchat", abortSignal: abortController.signal, }, defaultRuntime, diff --git a/src/gateway/server-methods/providers.ts b/src/gateway/server-methods/providers.ts index 6b794f749..00c2f7e10 100644 --- a/src/gateway/server-methods/providers.ts +++ b/src/gateway/server-methods/providers.ts @@ -6,7 +6,6 @@ import { } from "../../config/config.js"; import { type DiscordProbe, probeDiscord } from "../../discord/probe.js"; import { type IMessageProbe, probeIMessage } from "../../imessage/probe.js"; -import { webAuthExists } from "../../providers/web/index.js"; import { probeSignal, type SignalProbe } from "../../signal/probe.js"; import { probeSlack, type SlackProbe } from "../../slack/probe.js"; import { @@ -15,7 +14,15 @@ import { } from "../../slack/token.js"; import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js"; import { resolveTelegramToken } from "../../telegram/token.js"; -import { getWebAuthAgeMs, readWebSelfId } from "../../web/session.js"; +import { + listEnabledWhatsAppAccounts, + resolveDefaultWhatsAppAccountId, +} from "../../web/accounts.js"; +import { + getWebAuthAgeMs, + readWebSelfId, + webAuthExists, +} from "../../web/session.js"; import { ErrorCodes, errorShape, @@ -148,10 +155,55 @@ export const providersHandlers: GatewayRequestHandlers = { imessageLastProbeAt = Date.now(); } - const linked = await webAuthExists(); - const authAgeMs = getWebAuthAgeMs(); - const self = readWebSelfId(); const runtime = context.getRuntimeSnapshot(); + const defaultWhatsAppAccountId = resolveDefaultWhatsAppAccountId(cfg); + const enabledWhatsAppAccounts = listEnabledWhatsAppAccounts(cfg); + const defaultWhatsAppAccount = + enabledWhatsAppAccounts.find( + (account) => account.accountId === defaultWhatsAppAccountId, + ) ?? enabledWhatsAppAccounts[0]; + const linked = defaultWhatsAppAccount + ? await webAuthExists(defaultWhatsAppAccount.authDir) + : false; + const authAgeMs = defaultWhatsAppAccount + ? getWebAuthAgeMs(defaultWhatsAppAccount.authDir) + : null; + const self = defaultWhatsAppAccount + ? readWebSelfId(defaultWhatsAppAccount.authDir) + : { e164: null, jid: null }; + + const defaultWhatsAppStatus = { + running: false, + connected: false, + reconnectAttempts: 0, + lastConnectedAt: null, + lastDisconnect: null, + lastMessageAt: null, + lastEventAt: null, + lastError: null, + } as const; + const whatsappAccounts = await Promise.all( + enabledWhatsAppAccounts.map(async (account) => { + const rt = + runtime.whatsappAccounts?.[account.accountId] ?? + defaultWhatsAppStatus; + return { + accountId: account.accountId, + enabled: account.enabled, + linked: await webAuthExists(account.authDir), + authAgeMs: getWebAuthAgeMs(account.authDir), + self: readWebSelfId(account.authDir), + running: rt.running, + connected: rt.connected, + lastConnectedAt: rt.lastConnectedAt ?? null, + lastDisconnect: rt.lastDisconnect ?? null, + reconnectAttempts: rt.reconnectAttempts, + lastMessageAt: rt.lastMessageAt ?? null, + lastEventAt: rt.lastEventAt ?? null, + lastError: rt.lastError ?? null, + }; + }), + ); respond( true, @@ -171,6 +223,8 @@ export const providersHandlers: GatewayRequestHandlers = { lastEventAt: runtime.whatsapp.lastEventAt ?? null, lastError: runtime.whatsapp.lastError ?? null, }, + whatsappAccounts, + whatsappDefaultAccountId: defaultWhatsAppAccountId, telegram: { configured: telegramEnabled && Boolean(telegramToken), tokenSource, diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 65461385a..9e9f1e277 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -6,6 +6,7 @@ import { sendMessageSignal } from "../../signal/index.js"; import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageTelegram } from "../../telegram/send.js"; import { resolveTelegramToken } from "../../telegram/token.js"; +import { resolveDefaultWhatsAppAccountId } from "../../web/accounts.js"; import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; import { ErrorCodes, @@ -37,6 +38,7 @@ export const sendHandlers: GatewayRequestHandlers = { mediaUrl?: string; gifPlayback?: boolean; provider?: string; + accountId?: string; idempotencyKey: string; }; const idem = request.idempotencyKey; @@ -148,10 +150,17 @@ export const sendHandlers: GatewayRequestHandlers = { }); respond(true, payload, undefined, { provider }); } else { + const cfg = loadConfig(); + const accountId = + typeof request.accountId === "string" && + request.accountId.trim().length > 0 + ? request.accountId.trim() + : resolveDefaultWhatsAppAccountId(cfg); const result = await sendMessageWhatsApp(to, message, { mediaUrl: request.mediaUrl, verbose: shouldLogVerbose(), gifPlayback: request.gifPlayback, + accountId, }); const payload = { runId: idem, @@ -199,6 +208,7 @@ export const sendHandlers: GatewayRequestHandlers = { maxSelections?: number; durationHours?: number; provider?: string; + accountId?: string; idempotencyKey: string; }; const idem = request.idempotencyKey; @@ -245,8 +255,15 @@ export const sendHandlers: GatewayRequestHandlers = { }); respond(true, payload, undefined, { provider }); } else { + const cfg = loadConfig(); + const accountId = + typeof request.accountId === "string" && + request.accountId.trim().length > 0 + ? request.accountId.trim() + : resolveDefaultWhatsAppAccountId(cfg); const result = await sendPollWhatsApp(to, poll, { verbose: shouldLogVerbose(), + accountId, }); const payload = { runId: idem, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 4c45d22ea..24074ae07 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -24,11 +24,11 @@ import { loadConfig } from "../../config/config.js"; import { loadSessionStore, resolveMainSessionKey, - resolveStorePath, type SessionEntry, saveSessionStore, } from "../../config/sessions.js"; import { clearCommandLane } from "../../process/command-queue.js"; +import { isSubagentSessionKey } from "../../routing/session-key.js"; import { normalizeSendPolicy } from "../../sessions/send-policy.js"; import { ErrorCodes, @@ -43,7 +43,8 @@ import { import { archiveFileOnDisk, listSessionsFromStore, - loadSessionEntry, + loadCombinedSessionStoreForGateway, + resolveGatewaySessionStoreTarget, resolveSessionTranscriptCandidates, type SessionsPatchResult, } from "../session-utils.js"; @@ -64,8 +65,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { } const p = params as import("../protocol/index.js").SessionsListParams; const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.session?.store); - const store = loadSessionStore(storePath); + const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); const result = listSessionsFromStore({ cfg, storePath, @@ -98,11 +98,18 @@ export const sessionsHandlers: GatewayRequestHandlers = { } const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.session?.store); + const target = resolveGatewaySessionStoreTarget({ cfg, key }); + const storePath = target.storePath; const store = loadSessionStore(storePath); const now = Date.now(); - const existing = store[key]; + const primaryKey = target.storeKeys[0] ?? key; + const existingKey = target.storeKeys.find((candidate) => store[candidate]); + if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { + store[primaryKey] = store[existingKey]; + delete store[existingKey]; + } + const existing = store[primaryKey]; const next: SessionEntry = existing ? { ...existing, @@ -134,7 +141,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { ); return; } - if (!key.startsWith("subagent:")) { + if (!isSubagentSessionKey(primaryKey)) { respond( false, undefined, @@ -311,12 +318,12 @@ export const sessionsHandlers: GatewayRequestHandlers = { } } - store[key] = next; + store[primaryKey] = next; await saveSessionStore(storePath, store); const result: SessionsPatchResult = { ok: true, path: storePath, - key, + key: target.canonicalKey, entry: next, }; respond(true, result, undefined); @@ -344,7 +351,17 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } - const { storePath, store, entry } = loadSessionEntry(key); + const cfg = loadConfig(); + const target = resolveGatewaySessionStoreTarget({ cfg, key }); + const storePath = target.storePath; + const store = loadSessionStore(storePath); + const primaryKey = target.storeKeys[0] ?? key; + const existingKey = target.storeKeys.find((candidate) => store[candidate]); + if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { + store[primaryKey] = store[existingKey]; + delete store[existingKey]; + } + const entry = store[primaryKey]; const now = Date.now(); const next: SessionEntry = { sessionId: randomUUID(), @@ -356,13 +373,17 @@ export const sessionsHandlers: GatewayRequestHandlers = { model: entry?.model, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, skillsSnapshot: entry?.skillsSnapshot, }; - store[key] = next; + store[primaryKey] = next; await saveSessionStore(storePath, store); - respond(true, { ok: true, key, entry: next }, undefined); + respond( + true, + { ok: true, key: target.canonicalKey, entry: next }, + undefined, + ); }, "sessions.delete": async ({ params, respond }) => { if (!validateSessionsDeleteParams(params)) { @@ -387,8 +408,10 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } - const mainKey = resolveMainSessionKey(loadConfig()); - if (key === mainKey) { + const cfg = loadConfig(); + const mainKey = resolveMainSessionKey(cfg); + const target = resolveGatewaySessionStoreTarget({ cfg, key }); + if (target.canonicalKey === mainKey) { respond( false, undefined, @@ -403,10 +426,18 @@ export const sessionsHandlers: GatewayRequestHandlers = { const deleteTranscript = typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true; - const { storePath, store, entry } = loadSessionEntry(key); + const storePath = target.storePath; + const store = loadSessionStore(storePath); + const primaryKey = target.storeKeys[0] ?? key; + const existingKey = target.storeKeys.find((candidate) => store[candidate]); + if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { + store[primaryKey] = store[existingKey]; + delete store[existingKey]; + } + const entry = store[primaryKey]; const sessionId = entry?.sessionId; - const existed = Boolean(store[key]); - clearCommandLane(resolveEmbeddedSessionLane(key)); + const existed = Boolean(entry); + clearCommandLane(resolveEmbeddedSessionLane(target.canonicalKey)); if (sessionId && isEmbeddedPiRunActive(sessionId)) { abortEmbeddedPiRun(sessionId); const ended = await waitForEmbeddedPiRunEnd(sessionId, 15_000); @@ -422,7 +453,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } } - if (existed) delete store[key]; + if (existed) delete store[primaryKey]; await saveSessionStore(storePath, store); const archived: string[] = []; @@ -430,6 +461,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { for (const candidate of resolveSessionTranscriptCandidates( sessionId, storePath, + target.agentId, )) { if (!fs.existsSync(candidate)) continue; try { @@ -440,7 +472,11 @@ export const sessionsHandlers: GatewayRequestHandlers = { } } - respond(true, { ok: true, key, deleted: existed, archived }, undefined); + respond( + true, + { ok: true, key: target.canonicalKey, deleted: existed, archived }, + undefined, + ); }, "sessions.compact": async ({ params, respond }) => { if (!validateSessionsCompactParams(params)) { @@ -470,12 +506,27 @@ export const sessionsHandlers: GatewayRequestHandlers = { ? Math.max(1, Math.floor(p.maxLines)) : 400; - const { storePath, store, entry } = loadSessionEntry(key); + const cfg = loadConfig(); + const target = resolveGatewaySessionStoreTarget({ cfg, key }); + const storePath = target.storePath; + const store = loadSessionStore(storePath); + const primaryKey = target.storeKeys[0] ?? key; + const existingKey = target.storeKeys.find((candidate) => store[candidate]); + if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { + store[primaryKey] = store[existingKey]; + delete store[existingKey]; + } + const entry = store[primaryKey]; const sessionId = entry?.sessionId; if (!sessionId) { respond( true, - { ok: true, key, compacted: false, reason: "no sessionId" }, + { + ok: true, + key: target.canonicalKey, + compacted: false, + reason: "no sessionId", + }, undefined, ); return; @@ -484,11 +535,17 @@ export const sessionsHandlers: GatewayRequestHandlers = { const filePath = resolveSessionTranscriptCandidates( sessionId, storePath, + target.agentId, ).find((candidate) => fs.existsSync(candidate)); if (!filePath) { respond( true, - { ok: true, key, compacted: false, reason: "no transcript" }, + { + ok: true, + key: target.canonicalKey, + compacted: false, + reason: "no transcript", + }, undefined, ); return; @@ -499,7 +556,12 @@ export const sessionsHandlers: GatewayRequestHandlers = { if (lines.length <= maxLines) { respond( true, - { ok: true, key, compacted: false, kept: lines.length }, + { + ok: true, + key: target.canonicalKey, + compacted: false, + kept: lines.length, + }, undefined, ); return; @@ -509,11 +571,11 @@ export const sessionsHandlers: GatewayRequestHandlers = { const keptLines = lines.slice(-maxLines); fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8"); - if (store[key]) { - delete store[key].inputTokens; - delete store[key].outputTokens; - delete store[key].totalTokens; - store[key].updatedAt = Date.now(); + if (store[primaryKey]) { + delete store[primaryKey].inputTokens; + delete store[primaryKey].outputTokens; + delete store[primaryKey].totalTokens; + store[primaryKey].updatedAt = Date.now(); await saveSessionStore(storePath, store); } @@ -521,7 +583,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { true, { ok: true, - key, + key: target.canonicalKey, compacted: true, archived, kept: keptLines.length, diff --git a/src/gateway/server-methods/types.ts b/src/gateway/server-methods/types.ts index 7bd607963..04b7897bf 100644 --- a/src/gateway/server-methods/types.ts +++ b/src/gateway/server-methods/types.ts @@ -69,10 +69,10 @@ export type GatewayRequestContext = { findRunningWizard: () => string | null; purgeWizardSession: (id: string) => void; getRuntimeSnapshot: () => ProviderRuntimeSnapshot; - startWhatsAppProvider: () => Promise; - stopWhatsAppProvider: () => Promise; + startWhatsAppProvider: (accountId?: string) => Promise; + stopWhatsAppProvider: (accountId?: string) => Promise; stopTelegramProvider: () => Promise; - markWhatsAppLoggedOut: (cleared: boolean) => void; + markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void; wizardRunner: ( opts: import("../../commands/onboard-types.js").OnboardOptions, runtime: import("../../runtime.js").RuntimeEnv, diff --git a/src/gateway/server-methods/web.ts b/src/gateway/server-methods/web.ts index 9e7761b47..9fa9c8b7f 100644 --- a/src/gateway/server-methods/web.ts +++ b/src/gateway/server-methods/web.ts @@ -1,4 +1,6 @@ +import { loadConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; +import { resolveWhatsAppAccount } from "../../web/accounts.js"; import { startWebLoginWithQr, waitForWebLogin } from "../../web/login-qr.js"; import { logoutWeb } from "../../web/session.js"; import { @@ -25,7 +27,11 @@ export const webHandlers: GatewayRequestHandlers = { return; } try { - await context.stopWhatsAppProvider(); + const accountId = + typeof (params as { accountId?: unknown }).accountId === "string" + ? (params as { accountId?: string }).accountId + : undefined; + await context.stopWhatsAppProvider(accountId); const result = await startWebLoginWithQr({ force: Boolean((params as { force?: boolean }).force), timeoutMs: @@ -33,6 +39,7 @@ export const webHandlers: GatewayRequestHandlers = { ? (params as { timeoutMs?: number }).timeoutMs : undefined, verbose: Boolean((params as { verbose?: boolean }).verbose), + accountId, }); respond(true, result, undefined); } catch (err) { @@ -56,14 +63,19 @@ export const webHandlers: GatewayRequestHandlers = { return; } try { + const accountId = + typeof (params as { accountId?: unknown }).accountId === "string" + ? (params as { accountId?: string }).accountId + : undefined; const result = await waitForWebLogin({ timeoutMs: typeof (params as { timeoutMs?: unknown }).timeoutMs === "number" ? (params as { timeoutMs?: number }).timeoutMs : undefined, + accountId, }); if (result.connected) { - await context.startWhatsAppProvider(); + await context.startWhatsAppProvider(accountId); } respond(true, result, undefined); } catch (err) { @@ -74,11 +86,26 @@ export const webHandlers: GatewayRequestHandlers = { ); } }, - "web.logout": async ({ respond, context }) => { + "web.logout": async ({ params, respond, context }) => { try { - await context.stopWhatsAppProvider(); - const cleared = await logoutWeb(defaultRuntime); - context.markWhatsAppLoggedOut(cleared); + const rawAccountId = + params && typeof params === "object" && "accountId" in params + ? (params as { accountId?: unknown }).accountId + : undefined; + const accountId = + typeof rawAccountId === "string" ? rawAccountId.trim() : ""; + const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ + cfg, + accountId: accountId || undefined, + }); + await context.stopWhatsAppProvider(account.accountId); + const cleared = await logoutWeb({ + authDir: account.authDir, + isLegacyAuthDir: account.isLegacyAuthDir, + runtime: defaultRuntime, + }); + context.markWhatsAppLoggedOut(cleared, account.accountId); respond(true, { cleared }, undefined); } catch (err) { respond( diff --git a/src/gateway/server-providers.ts b/src/gateway/server-providers.ts index b204e2b61..84c94e966 100644 --- a/src/gateway/server-providers.ts +++ b/src/gateway/server-providers.ts @@ -15,6 +15,10 @@ import { import { monitorTelegramProvider } from "../telegram/monitor.js"; import { probeTelegram } from "../telegram/probe.js"; import { resolveTelegramToken } from "../telegram/token.js"; +import { + listEnabledWhatsAppAccounts, + resolveDefaultWhatsAppAccountId, +} from "../web/accounts.js"; import type { WebProviderStatus } from "../web/auto-reply.js"; import { readWebSelfId } from "../web/session.js"; import { formatError } from "./server-utils.js"; @@ -60,6 +64,7 @@ export type IMessageRuntimeStatus = { export type ProviderRuntimeSnapshot = { whatsapp: WebProviderStatus; + whatsappAccounts?: Record; telegram: TelegramRuntimeStatus; discord: DiscordRuntimeStatus; slack: SlackRuntimeStatus; @@ -88,8 +93,8 @@ type ProviderManagerOptions = { export type ProviderManager = { getRuntimeSnapshot: () => ProviderRuntimeSnapshot; startProviders: () => Promise; - startWhatsAppProvider: () => Promise; - stopWhatsAppProvider: () => Promise; + startWhatsAppProvider: (accountId?: string) => Promise; + stopWhatsAppProvider: (accountId?: string) => Promise; startTelegramProvider: () => Promise; stopTelegramProvider: () => Promise; startDiscordProvider: () => Promise; @@ -100,7 +105,7 @@ export type ProviderManager = { stopSignalProvider: () => Promise; startIMessageProvider: () => Promise; stopIMessageProvider: () => Promise; - markWhatsAppLoggedOut: (cleared: boolean) => void; + markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void; }; export function createProviderManager( @@ -122,20 +127,21 @@ export function createProviderManager( imessageRuntimeEnv, } = opts; - let whatsappAbort: AbortController | null = null; + const whatsappAborts = new Map(); let telegramAbort: AbortController | null = null; let discordAbort: AbortController | null = null; let slackAbort: AbortController | null = null; let signalAbort: AbortController | null = null; let imessageAbort: AbortController | null = null; - let whatsappTask: Promise | null = null; + const whatsappTasks = new Map>(); let telegramTask: Promise | null = null; let discordTask: Promise | null = null; let slackTask: Promise | null = null; let signalTask: Promise | null = null; let imessageTask: Promise | null = null; - let whatsappRuntime: WebProviderStatus = { + const whatsappRuntimes = new Map(); + const defaultWhatsAppStatus = (): WebProviderStatus => ({ running: false, connected: false, reconnectAttempts: 0, @@ -144,7 +150,7 @@ export function createProviderManager( lastMessageAt: null, lastEventAt: null, lastError: null, - }; + }); let telegramRuntime: TelegramRuntimeStatus = { running: false, lastStartAt: null, @@ -180,86 +186,134 @@ export function createProviderManager( dbPath: null, }; - const updateWhatsAppStatus = (next: WebProviderStatus) => { - whatsappRuntime = next; + const updateWhatsAppStatus = (accountId: string, next: WebProviderStatus) => { + whatsappRuntimes.set(accountId, next); }; - const startWhatsAppProvider = async () => { - if (whatsappTask) return; + const startWhatsAppProvider = async (accountId?: string) => { const cfg = loadConfig(); + const enabledAccounts = listEnabledWhatsAppAccounts(cfg); + const targets = accountId + ? enabledAccounts.filter((a) => a.accountId === accountId) + : enabledAccounts; + if (targets.length === 0) return; + if (cfg.web?.enabled === false) { - whatsappRuntime = { - ...whatsappRuntime, - running: false, - connected: false, - lastError: "disabled", - }; + for (const account of targets) { + const current = + whatsappRuntimes.get(account.accountId) ?? defaultWhatsAppStatus(); + whatsappRuntimes.set(account.accountId, { + ...current, + running: false, + connected: false, + lastError: "disabled", + }); + } logWhatsApp.info("skipping provider start (web.enabled=false)"); return; } - if (!(await webAuthExists())) { - whatsappRuntime = { - ...whatsappRuntime, - running: false, - connected: false, - lastError: "not linked", - }; - logWhatsApp.info("skipping provider start (no linked session)"); - return; - } - const { e164, jid } = readWebSelfId(); - const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown"; - logWhatsApp.info(`starting provider (${identity})`); - whatsappAbort = new AbortController(); - whatsappRuntime = { - ...whatsappRuntime, - running: true, - connected: false, - lastError: null, - }; - const task = monitorWebProvider( - shouldLogVerbose(), - undefined, - true, - undefined, - whatsappRuntimeEnv, - whatsappAbort.signal, - { statusSink: updateWhatsAppStatus }, - ) - .catch((err) => { - whatsappRuntime = { - ...whatsappRuntime, - lastError: formatError(err), - }; - logWhatsApp.error(`provider exited: ${formatError(err)}`); - }) - .finally(() => { - whatsappAbort = null; - whatsappTask = null; - whatsappRuntime = { - ...whatsappRuntime, - running: false, + + await Promise.all( + targets.map(async (account) => { + if (whatsappTasks.has(account.accountId)) return; + const current = + whatsappRuntimes.get(account.accountId) ?? defaultWhatsAppStatus(); + if (!(await webAuthExists(account.authDir))) { + whatsappRuntimes.set(account.accountId, { + ...current, + running: false, + connected: false, + lastError: "not linked", + }); + logWhatsApp.info( + `[${account.accountId}] skipping provider start (no linked session)`, + ); + return; + } + + const { e164, jid } = readWebSelfId(account.authDir); + const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown"; + logWhatsApp.info( + `[${account.accountId}] starting provider (${identity})`, + ); + const abort = new AbortController(); + whatsappAborts.set(account.accountId, abort); + whatsappRuntimes.set(account.accountId, { + ...current, + running: true, connected: false, - }; - }); - whatsappTask = task; + lastError: null, + }); + + const task = monitorWebProvider( + shouldLogVerbose(), + undefined, + true, + undefined, + whatsappRuntimeEnv, + abort.signal, + { + statusSink: (next) => updateWhatsAppStatus(account.accountId, next), + accountId: account.accountId, + }, + ) + .catch((err) => { + const latest = + whatsappRuntimes.get(account.accountId) ?? + defaultWhatsAppStatus(); + whatsappRuntimes.set(account.accountId, { + ...latest, + lastError: formatError(err), + }); + logWhatsApp.error( + `[${account.accountId}] provider exited: ${formatError(err)}`, + ); + }) + .finally(() => { + whatsappAborts.delete(account.accountId); + whatsappTasks.delete(account.accountId); + const latest = + whatsappRuntimes.get(account.accountId) ?? + defaultWhatsAppStatus(); + whatsappRuntimes.set(account.accountId, { + ...latest, + running: false, + connected: false, + }); + }); + + whatsappTasks.set(account.accountId, task); + }), + ); }; - const stopWhatsAppProvider = async () => { - if (!whatsappAbort && !whatsappTask) return; - whatsappAbort?.abort(); - try { - await whatsappTask; - } catch { - // ignore - } - whatsappAbort = null; - whatsappTask = null; - whatsappRuntime = { - ...whatsappRuntime, - running: false, - connected: false, - }; + const stopWhatsAppProvider = async (accountId?: string) => { + const ids = accountId + ? [accountId] + : Array.from( + new Set([...whatsappAborts.keys(), ...whatsappTasks.keys()]), + ); + await Promise.all( + ids.map(async (id) => { + const abort = whatsappAborts.get(id); + const task = whatsappTasks.get(id); + if (!abort && !task) return; + abort?.abort(); + try { + await task; + } catch { + // ignore + } + whatsappAborts.delete(id); + whatsappTasks.delete(id); + const latest = whatsappRuntimes.get(id) ?? defaultWhatsAppStatus(); + whatsappRuntimes.set(id, { + ...latest, + running: false, + connected: false, + }); + }), + ); }; const startTelegramProvider = async () => { @@ -754,23 +808,38 @@ export function createProviderManager( await startIMessageProvider(); }; - const markWhatsAppLoggedOut = (cleared: boolean) => { - whatsappRuntime = { - ...whatsappRuntime, + const markWhatsAppLoggedOut = (cleared: boolean, accountId?: string) => { + const cfg = loadConfig(); + const resolvedId = accountId ?? resolveDefaultWhatsAppAccountId(cfg); + const current = whatsappRuntimes.get(resolvedId) ?? defaultWhatsAppStatus(); + whatsappRuntimes.set(resolvedId, { + ...current, running: false, connected: false, - lastError: cleared ? "logged out" : whatsappRuntime.lastError, - }; + lastError: cleared ? "logged out" : current.lastError, + }); }; - const getRuntimeSnapshot = (): ProviderRuntimeSnapshot => ({ - whatsapp: { ...whatsappRuntime }, - telegram: { ...telegramRuntime }, - discord: { ...discordRuntime }, - slack: { ...slackRuntime }, - signal: { ...signalRuntime }, - imessage: { ...imessageRuntime }, - }); + const getRuntimeSnapshot = (): ProviderRuntimeSnapshot => { + const cfg = loadConfig(); + const defaultId = resolveDefaultWhatsAppAccountId(cfg); + const whatsapp = whatsappRuntimes.get(defaultId) ?? defaultWhatsAppStatus(); + const whatsappAccounts = Object.fromEntries( + Array.from(whatsappRuntimes.entries()).map(([id, status]) => [ + id, + { ...status }, + ]), + ); + return { + whatsapp: { ...whatsapp }, + whatsappAccounts, + telegram: { ...telegramRuntime }, + discord: { ...discordRuntime }, + slack: { ...slackRuntime }, + signal: { ...signalRuntime }, + imessage: { ...imessageRuntime }, + }; + }; return { getRuntimeSnapshot, diff --git a/src/gateway/server.agent.test.ts b/src/gateway/server.agent.test.ts index 9edea8f38..a13df9206 100644 --- a/src/gateway/server.agent.test.ts +++ b/src/gateway/server.agent.test.ts @@ -33,7 +33,7 @@ describe("gateway server agent", () => { main: { sessionId: "sess-main-stale", updatedAt: Date.now(), - lastChannel: "whatsapp", + lastProvider: "whatsapp", lastTo: "+1555", }, }, @@ -49,7 +49,7 @@ describe("gateway server agent", () => { const res = await rpcReq(ws, "agent", { message: "hi", sessionKey: "main", - channel: "last", + provider: "last", deliver: true, idempotencyKey: "idem-agent-last-stale", }); @@ -76,7 +76,7 @@ describe("gateway server agent", () => { main: { sessionId: "sess-main-whatsapp", updatedAt: Date.now(), - lastChannel: "whatsapp", + lastProvider: "whatsapp", lastTo: "+1555", }, }, @@ -92,7 +92,7 @@ describe("gateway server agent", () => { const res = await rpcReq(ws, "agent", { message: "hi", sessionKey: "main", - channel: "last", + provider: "last", deliver: true, idempotencyKey: "idem-agent-last-whatsapp", }); @@ -120,7 +120,7 @@ describe("gateway server agent", () => { main: { sessionId: "sess-main", updatedAt: Date.now(), - lastChannel: "telegram", + lastProvider: "telegram", lastTo: "123", }, }, @@ -136,7 +136,7 @@ describe("gateway server agent", () => { const res = await rpcReq(ws, "agent", { message: "hi", sessionKey: "main", - channel: "last", + provider: "last", deliver: true, idempotencyKey: "idem-agent-last", }); @@ -164,7 +164,7 @@ describe("gateway server agent", () => { main: { sessionId: "sess-discord", updatedAt: Date.now(), - lastChannel: "discord", + lastProvider: "discord", lastTo: "channel:discord-123", }, }, @@ -180,7 +180,7 @@ describe("gateway server agent", () => { const res = await rpcReq(ws, "agent", { message: "hi", sessionKey: "main", - channel: "last", + provider: "last", deliver: true, idempotencyKey: "idem-agent-last-discord", }); @@ -208,7 +208,7 @@ describe("gateway server agent", () => { main: { sessionId: "sess-signal", updatedAt: Date.now(), - lastChannel: "signal", + lastProvider: "signal", lastTo: "+15551234567", }, }, @@ -224,7 +224,7 @@ describe("gateway server agent", () => { const res = await rpcReq(ws, "agent", { message: "hi", sessionKey: "main", - channel: "last", + provider: "last", deliver: true, idempotencyKey: "idem-agent-last-signal", }); @@ -253,7 +253,7 @@ describe("gateway server agent", () => { main: { sessionId: "sess-main-webchat", updatedAt: Date.now(), - lastChannel: "webchat", + lastProvider: "webchat", lastTo: "+1555", }, }, @@ -269,7 +269,7 @@ describe("gateway server agent", () => { const res = await rpcReq(ws, "agent", { message: "hi", sessionKey: "main", - channel: "last", + provider: "last", deliver: true, idempotencyKey: "idem-agent-webchat", }); diff --git a/src/gateway/server.chat.test.ts b/src/gateway/server.chat.test.ts index 18c078a79..748203ee0 100644 --- a/src/gateway/server.chat.test.ts +++ b/src/gateway/server.chat.test.ts @@ -70,7 +70,7 @@ describe("gateway server chat", () => { rules: [ { action: "deny", - match: { surface: "discord", chatType: "group" }, + match: { provider: "discord", chatType: "group" }, }, ], }, @@ -84,7 +84,7 @@ describe("gateway server chat", () => { sessionId: "sess-discord", updatedAt: Date.now(), chatType: "group", - surface: "discord", + provider: "discord", }, }, null, @@ -423,7 +423,7 @@ describe("gateway server chat", () => { main: { sessionId: "sess-main", updatedAt: Date.now(), - lastChannel: "whatsapp", + lastProvider: "whatsapp", lastTo: "+1555", }, }, @@ -446,9 +446,9 @@ describe("gateway server chat", () => { const stored = JSON.parse( await fs.readFile(testState.sessionStorePath, "utf-8"), ) as { - main?: { lastChannel?: string; lastTo?: string }; + main?: { lastProvider?: string; lastTo?: string }; }; - expect(stored.main?.lastChannel).toBe("whatsapp"); + expect(stored.main?.lastProvider).toBe("whatsapp"); expect(stored.main?.lastTo).toBe("+1555"); ws.close(); diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts index defe83a99..c68744089 100644 --- a/src/gateway/server.hooks.test.ts +++ b/src/gateway/server.hooks.test.ts @@ -86,7 +86,7 @@ describe("gateway server hooks", () => { await server.close(); }); - test("hooks agent rejects invalid channel", async () => { + test("hooks agent rejects invalid provider", async () => { testState.hooksConfig = { enabled: true, token: "hook-secret" }; const port = await getFreePort(); const server = await startGatewayServer(port); @@ -96,7 +96,7 @@ describe("gateway server hooks", () => { "Content-Type": "application/json", Authorization: "Bearer hook-secret", }, - body: JSON.stringify({ message: "Nope", channel: "sms" }), + body: JSON.stringify({ message: "Nope", provider: "sms" }), }); expect(res.status).toBe(400); expect(peekSystemEvents().length).toBe(0); diff --git a/src/gateway/server.node-bridge.test.ts b/src/gateway/server.node-bridge.test.ts index fc1e29fc8..3b25d4c8d 100644 --- a/src/gateway/server.node-bridge.test.ts +++ b/src/gateway/server.node-bridge.test.ts @@ -732,7 +732,7 @@ describe("gateway server node/bridge", () => { main: { sessionId: "sess-main", updatedAt: Date.now(), - lastChannel: "whatsapp", + lastProvider: "whatsapp", lastTo: "+1555", }, }, @@ -759,7 +759,7 @@ describe("gateway server node/bridge", () => { const call = spy.mock.calls.at(-1)?.[0] as Record; expect(call.sessionId).toBe("sess-main"); expect(call.deliver).toBe(false); - expect(call.surface).toBe("Node"); + expect(call.messageProvider).toBe("node"); const stored = JSON.parse( await fs.readFile(testState.sessionStorePath, "utf-8"), diff --git a/src/gateway/server.sessions.test.ts b/src/gateway/server.sessions.test.ts index 590e1c774..d700c2d65 100644 --- a/src/gateway/server.sessions.test.ts +++ b/src/gateway/server.sessions.test.ts @@ -40,23 +40,26 @@ describe("gateway server sessions", () => { storePath, JSON.stringify( { - main: { + "agent:main:main": { sessionId: "sess-main", updatedAt: now - 30_000, inputTokens: 10, outputTokens: 20, thinkingLevel: "low", verboseLevel: "on", + lastProvider: "whatsapp", + lastTo: "+1555", + lastAccountId: "work", }, - "discord:group:dev": { + "agent:main:discord:group:dev": { sessionId: "sess-group", updatedAt: now - 120_000, totalTokens: 50, }, - "subagent:one": { + "agent:main:subagent:one": { sessionId: "sess-subagent", updatedAt: now - 120_000, - spawnedBy: "main", + spawnedBy: "agent:main:main", }, global: { sessionId: "sess-global", @@ -91,16 +94,20 @@ describe("gateway server sessions", () => { totalTokens?: number; thinkingLevel?: string; verboseLevel?: string; + lastAccountId?: string; }>; }>(ws, "sessions.list", { includeGlobal: false, includeUnknown: false }); expect(list1.ok).toBe(true); expect(list1.payload?.path).toBe(storePath); expect(list1.payload?.sessions.some((s) => s.key === "global")).toBe(false); - const main = list1.payload?.sessions.find((s) => s.key === "main"); + const main = list1.payload?.sessions.find( + (s) => s.key === "agent:main:main", + ); expect(main?.totalTokens).toBe(30); expect(main?.thinkingLevel).toBe("low"); expect(main?.verboseLevel).toBe("on"); + expect(main?.lastAccountId).toBe("work"); const active = await rpcReq<{ sessions: Array<{ key: string }>; @@ -110,7 +117,9 @@ describe("gateway server sessions", () => { activeMinutes: 1, }); expect(active.ok).toBe(true); - expect(active.payload?.sessions.map((s) => s.key)).toEqual(["main"]); + expect(active.payload?.sessions.map((s) => s.key)).toEqual([ + "agent:main:main", + ]); const limited = await rpcReq<{ sessions: Array<{ key: string }>; @@ -126,16 +135,16 @@ describe("gateway server sessions", () => { const patched = await rpcReq<{ ok: true; key: string }>( ws, "sessions.patch", - { key: "main", thinkingLevel: "medium", verboseLevel: null }, + { key: "agent:main:main", thinkingLevel: "medium", verboseLevel: null }, ); expect(patched.ok).toBe(true); expect(patched.payload?.ok).toBe(true); - expect(patched.payload?.key).toBe("main"); + expect(patched.payload?.key).toBe("agent:main:main"); const sendPolicyPatched = await rpcReq<{ ok: true; entry: { sendPolicy?: string }; - }>(ws, "sessions.patch", { key: "main", sendPolicy: "deny" }); + }>(ws, "sessions.patch", { key: "agent:main:main", sendPolicy: "deny" }); expect(sendPolicyPatched.ok).toBe(true); expect(sendPolicyPatched.payload?.entry.sendPolicy).toBe("deny"); @@ -148,7 +157,9 @@ describe("gateway server sessions", () => { }>; }>(ws, "sessions.list", {}); expect(list2.ok).toBe(true); - const main2 = list2.payload?.sessions.find((s) => s.key === "main"); + const main2 = list2.payload?.sessions.find( + (s) => s.key === "agent:main:main", + ); expect(main2?.thinkingLevel).toBe("medium"); expect(main2?.verboseLevel).toBeUndefined(); expect(main2?.sendPolicy).toBe("deny"); @@ -158,23 +169,26 @@ describe("gateway server sessions", () => { }>(ws, "sessions.list", { includeGlobal: true, includeUnknown: true, - spawnedBy: "main", + spawnedBy: "agent:main:main", }); expect(spawnedOnly.ok).toBe(true); expect(spawnedOnly.payload?.sessions.map((s) => s.key)).toEqual([ - "subagent:one", + "agent:main:subagent:one", ]); const spawnedPatched = await rpcReq<{ ok: true; entry: { spawnedBy?: string }; - }>(ws, "sessions.patch", { key: "subagent:two", spawnedBy: "main" }); + }>(ws, "sessions.patch", { + key: "agent:main:subagent:two", + spawnedBy: "agent:main:main", + }); expect(spawnedPatched.ok).toBe(true); - expect(spawnedPatched.payload?.entry.spawnedBy).toBe("main"); + expect(spawnedPatched.payload?.entry.spawnedBy).toBe("agent:main:main"); const spawnedPatchedInvalidKey = await rpcReq(ws, "sessions.patch", { - key: "main", - spawnedBy: "main", + key: "agent:main:main", + spawnedBy: "agent:main:main", }); expect(spawnedPatchedInvalidKey.ok).toBe(false); @@ -183,7 +197,10 @@ describe("gateway server sessions", () => { const modelPatched = await rpcReq<{ ok: true; entry: { modelOverride?: string; providerOverride?: string }; - }>(ws, "sessions.patch", { key: "main", model: "openai/gpt-test-a" }); + }>(ws, "sessions.patch", { + key: "agent:main:main", + model: "openai/gpt-test-a", + }); expect(modelPatched.ok).toBe(true); expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a"); expect(modelPatched.payload?.entry.providerOverride).toBe("openai"); @@ -191,7 +208,7 @@ describe("gateway server sessions", () => { const compacted = await rpcReq<{ ok: true; compacted: boolean }>( ws, "sessions.compact", - { key: "main", maxLines: 3 }, + { key: "agent:main:main", maxLines: 3 }, ); expect(compacted.ok).toBe(true); expect(compacted.payload?.compacted).toBe(true); @@ -209,7 +226,7 @@ describe("gateway server sessions", () => { const deleted = await rpcReq<{ ok: true; deleted: boolean }>( ws, "sessions.delete", - { key: "discord:group:dev" }, + { key: "agent:main:discord:group:dev" }, ); expect(deleted.ok).toBe(true); expect(deleted.payload?.deleted).toBe(true); @@ -219,7 +236,7 @@ describe("gateway server sessions", () => { expect(listAfterDelete.ok).toBe(true); expect( listAfterDelete.payload?.sessions.some( - (s) => s.key === "discord:group:dev", + (s) => s.key === "agent:main:discord:group:dev", ), ).toBe(false); const filesAfterDelete = await fs.readdir(dir); @@ -231,13 +248,13 @@ describe("gateway server sessions", () => { ok: true; key: string; entry: { sessionId: string }; - }>(ws, "sessions.reset", { key: "main" }); + }>(ws, "sessions.reset", { key: "agent:main:main" }); expect(reset.ok).toBe(true); - expect(reset.payload?.key).toBe("main"); + expect(reset.payload?.key).toBe("agent:main:main"); expect(reset.payload?.entry.sessionId).not.toBe("sess-main"); const badThinking = await rpcReq(ws, "sessions.patch", { - key: "main", + key: "agent:main:main", thinkingLevel: "banana", }); expect(badThinking.ok).toBe(false); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 3080a5b80..523e7f3df 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -482,7 +482,7 @@ export async function startGatewayServer( wakeMode: "now" | "next-heartbeat"; sessionKey: string; deliver: boolean; - channel: + provider: | "last" | "whatsapp" | "telegram" @@ -514,7 +514,7 @@ export async function startGatewayServer( thinking: value.thinking, timeoutSeconds: value.timeoutSeconds, deliver: value.deliver, - channel: value.channel, + provider: value.provider, to: value.to, }, state: { nextRunAtMs: now }, diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 5a6368c15..9fa2cf36c 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -15,7 +15,7 @@ describe("gateway session utils", () => { test("parseGroupKey handles group prefixes", () => { expect(parseGroupKey("group:abc")).toEqual({ id: "abc" }); expect(parseGroupKey("discord:group:dev")).toEqual({ - surface: "discord", + provider: "discord", kind: "group", id: "dev", }); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 46bb66fce..e299ad0e4 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -9,12 +9,19 @@ import { } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import { type ClawdbotConfig, loadConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; import { buildGroupDisplayName, loadSessionStore, + resolveAgentIdFromSessionKey, + resolveSessionTranscriptPath, resolveStorePath, type SessionEntry, } from "../config/sessions.js"; +import { + normalizeAgentId, + parseAgentSessionKey, +} from "../routing/session-key.js"; export type GatewaySessionsDefaults = { model: string | null; @@ -25,7 +32,7 @@ export type GatewaySessionRow = { key: string; kind: "direct" | "group" | "global" | "unknown"; displayName?: string; - surface?: string; + provider?: string; subject?: string; room?: string; space?: string; @@ -43,8 +50,9 @@ export type GatewaySessionRow = { totalTokens?: number; model?: string; contextTokens?: number; - lastChannel?: SessionEntry["lastChannel"]; + lastProvider?: SessionEntry["lastProvider"]; lastTo?: string; + lastAccountId?: string; }; export type SessionsListResult = { @@ -90,12 +98,16 @@ export function readSessionMessages( export function resolveSessionTranscriptCandidates( sessionId: string, storePath: string | undefined, + agentId?: string, ): string[] { const candidates: string[] = []; if (storePath) { const dir = path.dirname(storePath); candidates.push(path.join(dir, `${sessionId}.jsonl`)); } + if (agentId) { + candidates.push(resolveSessionTranscriptPath(sessionId, agentId)); + } candidates.push( path.join(os.homedir(), ".clawdbot", "sessions", `${sessionId}.jsonl`), ); @@ -136,11 +148,12 @@ export function capArrayByJsonBytes( export function loadSessionEntry(sessionKey: string) { const cfg = loadConfig(); const sessionCfg = cfg.session; - const storePath = sessionCfg?.store - ? resolveStorePath(sessionCfg.store) - : resolveStorePath(undefined); + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolveStorePath(sessionCfg?.store, { agentId }); const store = loadSessionStore(storePath); - const entry = store[sessionKey]; + const parsed = parseAgentSessionKey(sessionKey); + const legacyKey = parsed?.rest; + const entry = store[sessionKey] ?? (legacyKey ? store[legacyKey] : undefined); return { cfg, storePath, store, entry }; } @@ -163,22 +176,167 @@ export function classifySessionKey( export function parseGroupKey( key: string, -): { surface?: string; kind?: "group" | "channel"; id?: string } | null { - if (key.startsWith("group:")) { - const raw = key.slice("group:".length); +): { provider?: string; kind?: "group" | "channel"; id?: string } | null { + const agentParsed = parseAgentSessionKey(key); + const rawKey = agentParsed?.rest ?? key; + if (rawKey.startsWith("group:")) { + const raw = rawKey.slice("group:".length); return raw ? { id: raw } : null; } - const parts = key.split(":").filter(Boolean); + const parts = rawKey.split(":").filter(Boolean); if (parts.length >= 3) { - const [surface, kind, ...rest] = parts; + const [provider, kind, ...rest] = parts; if (kind === "group" || kind === "channel") { const id = rest.join(":"); - return { surface, kind, id }; + return { provider, kind, id }; } } return null; } +function isStorePathTemplate(store?: string): boolean { + return typeof store === "string" && store.includes("{agentId}"); +} + +function listExistingAgentIdsFromDisk(): string[] { + const root = resolveStateDir(); + const agentsDir = path.join(root, "agents"); + try { + const entries = fs.readdirSync(agentsDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => normalizeAgentId(entry.name)) + .filter(Boolean); + } catch { + return []; + } +} + +function listConfiguredAgentIds(cfg: ClawdbotConfig): string[] { + const ids = new Set(); + const defaultId = normalizeAgentId(cfg.routing?.defaultAgentId); + ids.add(defaultId); + const agents = cfg.routing?.agents; + if (agents && typeof agents === "object") { + for (const id of Object.keys(agents)) ids.add(normalizeAgentId(id)); + } + for (const id of listExistingAgentIdsFromDisk()) ids.add(id); + const sorted = Array.from(ids).filter(Boolean); + sorted.sort((a, b) => a.localeCompare(b)); + if (sorted.includes(defaultId)) { + return [defaultId, ...sorted.filter((id) => id !== defaultId)]; + } + return sorted; +} + +function canonicalizeSessionKeyForAgent(agentId: string, key: string): string { + if (key === "global" || key === "unknown") return key; + if (key.startsWith("agent:")) return key; + return `agent:${normalizeAgentId(agentId)}:${key}`; +} + +function canonicalizeSpawnedByForAgent( + agentId: string, + spawnedBy?: string, +): string | undefined { + const raw = spawnedBy?.trim(); + if (!raw) return undefined; + if (raw === "global" || raw === "unknown") return raw; + if (raw.startsWith("agent:")) return raw; + return `agent:${normalizeAgentId(agentId)}:${raw}`; +} + +export function resolveGatewaySessionStoreTarget(params: { + cfg: ClawdbotConfig; + key: string; +}): { + agentId: string; + storePath: string; + canonicalKey: string; + storeKeys: string[]; +} { + const key = params.key.trim(); + const agentId = resolveAgentIdFromSessionKey(key); + const storeConfig = params.cfg.session?.store; + const storePath = resolveStorePath(storeConfig, { agentId }); + + if (key === "global" || key === "unknown") { + return { agentId, storePath, canonicalKey: key, storeKeys: [key] }; + } + + const parsed = parseAgentSessionKey(key); + if (parsed) { + return { + agentId, + storePath, + canonicalKey: key, + storeKeys: [key, parsed.rest], + }; + } + + if (key.startsWith("subagent:")) { + const canonical = canonicalizeSessionKeyForAgent(agentId, key); + return { + agentId, + storePath, + canonicalKey: canonical, + storeKeys: [canonical, key], + }; + } + + const canonical = canonicalizeSessionKeyForAgent(agentId, key); + return { + agentId, + storePath, + canonicalKey: canonical, + storeKeys: [canonical, key], + }; +} + +export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): { + storePath: string; + store: Record; +} { + const storeConfig = cfg.session?.store; + if (storeConfig && !isStorePathTemplate(storeConfig)) { + const storePath = resolveStorePath(storeConfig); + const defaultAgentId = normalizeAgentId(cfg.routing?.defaultAgentId); + const store = loadSessionStore(storePath); + const combined: Record = {}; + for (const [key, entry] of Object.entries(store)) { + const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key); + combined[canonicalKey] = { + ...entry, + spawnedBy: canonicalizeSpawnedByForAgent( + defaultAgentId, + entry.spawnedBy, + ), + }; + } + return { storePath, store: combined }; + } + + const agentIds = listConfiguredAgentIds(cfg); + const combined: Record = {}; + for (const agentId of agentIds) { + const storePath = resolveStorePath(storeConfig, { agentId }); + const store = loadSessionStore(storePath); + for (const [key, entry] of Object.entries(store)) { + const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key); + combined[canonicalKey] = { + ...entry, + spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy), + }; + } + } + + const storePath = + typeof storeConfig === "string" && storeConfig.trim() + ? storeConfig.trim() + : "(multiple)"; + return { storePath, store: combined }; +} + export function getSessionDefaults( cfg: ClawdbotConfig, ): GatewaySessionsDefaults { @@ -251,16 +409,16 @@ export function listSessionsFromStore(params: { const output = entry?.outputTokens ?? 0; const total = entry?.totalTokens ?? input + output; const parsed = parseGroupKey(key); - const surface = entry?.surface ?? parsed?.surface; + const provider = entry?.provider ?? parsed?.provider; const subject = entry?.subject; const room = entry?.room; const space = entry?.space; const id = parsed?.id; const displayName = entry?.displayName ?? - (surface + (provider ? buildGroupDisplayName({ - surface, + provider, subject, room, space, @@ -272,7 +430,7 @@ export function listSessionsFromStore(params: { key, kind: classifySessionKey(key, entry), displayName, - surface, + provider, subject, room, space, @@ -290,8 +448,9 @@ export function listSessionsFromStore(params: { totalTokens: total, model: entry?.model, contextTokens: entry?.contextTokens, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, + lastAccountId: entry?.lastAccountId, } satisfies GatewaySessionRow; }) .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); diff --git a/src/imessage/monitor.test.ts b/src/imessage/monitor.test.ts index 0a98d8749..773ff55c6 100644 --- a/src/imessage/monitor.test.ts +++ b/src/imessage/monitor.test.ts @@ -407,7 +407,7 @@ describe("monitorIMessageProvider", () => { expect(updateLastRouteMock).toHaveBeenCalledWith( expect.objectContaining({ - channel: "imessage", + provider: "imessage", to: "chat_id:7", }), ); diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index a9696f605..d6e1d42da 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -20,6 +20,7 @@ import { readProviderAllowFromStore, upsertProviderPairingRequest, } from "../pairing/pairing-store.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { createIMessageRpcClient } from "./client.js"; import { sendMessageIMessage } from "./send.js"; @@ -200,7 +201,7 @@ export async function monitorIMessageProvider( } const groupListPolicy = resolveProviderGroupPolicy({ cfg, - surface: "imessage", + provider: "imessage", groupId, }); if (groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) { @@ -277,7 +278,7 @@ export async function monitorIMessageProvider( : true; const requireMention = resolveProviderGroupRequireMention({ cfg, - surface: "imessage", + provider: "imessage", groupId, requireMentionOverride: opts.requireMention, overrideOrder: "before-config", @@ -334,16 +335,28 @@ export async function monitorIMessageProvider( ? Date.parse(message.created_at) : undefined; const body = formatAgentEnvelope({ - surface: "iMessage", + provider: "iMessage", from: fromLabel, timestamp: createdAt, body: bodyText, }); + const route = resolveAgentRoute({ + cfg, + provider: "imessage", + peer: { + kind: isGroup ? "group" : "dm", + id: isGroup + ? String(chatId ?? "unknown") + : normalizeIMessageHandle(sender), + }, + }); const ctxPayload = { Body: body, From: isGroup ? `group:${chatId}` : `imessage:${sender}`, To: chatTarget || `imessage:${sender}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, ChatType: isGroup ? "group" : "direct", GroupSubject: isGroup ? (message.chat_name ?? undefined) : undefined, GroupMembers: isGroup @@ -351,7 +364,7 @@ export async function monitorIMessageProvider( : undefined, SenderName: sender, SenderId: sender, - Surface: "imessage", + Provider: "imessage", MessageSid: message.id ? String(message.id) : undefined, Timestamp: createdAt, MediaPath: mediaPath, @@ -363,15 +376,17 @@ export async function monitorIMessageProvider( if (!isGroup) { const sessionCfg = cfg.session; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = resolveStorePath(sessionCfg?.store, { + agentId: route.agentId, + }); const to = chatTarget || sender; if (to) { await updateLastRoute({ storePath, - sessionKey: mainKey, - channel: "imessage", + sessionKey: route.mainSessionKey, + provider: "imessage", to, + accountId: route.accountId, }); } } diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index 107c66b9e..39a05c1fd 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -60,7 +60,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { agent: { heartbeat: { target: "none" } }, }; expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({ - channel: "none", + provider: "none", reason: "target-none", }); }); @@ -69,11 +69,11 @@ describe("resolveHeartbeatDeliveryTarget", () => { const cfg: ClawdbotConfig = {}; const entry = { ...baseEntry, - lastChannel: "whatsapp" as const, + lastProvider: "whatsapp" as const, lastTo: "+1555", }; expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({ - channel: "whatsapp", + provider: "whatsapp", to: "+1555", }); }); @@ -82,11 +82,11 @@ describe("resolveHeartbeatDeliveryTarget", () => { const cfg: ClawdbotConfig = {}; const entry = { ...baseEntry, - lastChannel: "webchat" as const, + lastProvider: "webchat" as const, lastTo: "web", }; expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({ - channel: "none", + provider: "none", reason: "no-target", }); }); @@ -98,11 +98,11 @@ describe("resolveHeartbeatDeliveryTarget", () => { }; const entry = { ...baseEntry, - lastChannel: "whatsapp" as const, + lastProvider: "whatsapp" as const, lastTo: "+1222", }; expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({ - channel: "whatsapp", + provider: "whatsapp", to: "+1555", reason: "allowFrom-fallback", }); @@ -113,7 +113,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { agent: { heartbeat: { target: "telegram", to: "123" } }, }; expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({ - channel: "telegram", + provider: "telegram", to: "123", }); }); @@ -132,7 +132,7 @@ describe("runHeartbeatOnce", () => { main: { sessionId: "sid", updatedAt: Date.now(), - lastChannel: "whatsapp", + lastProvider: "whatsapp", lastTo: "+1555", }, }, @@ -193,7 +193,7 @@ describe("runHeartbeatOnce", () => { main: { sessionId: "sid", updatedAt: Date.now(), - lastChannel: "whatsapp", + lastProvider: "whatsapp", lastTo: "+1555", }, }, @@ -251,7 +251,7 @@ describe("runHeartbeatOnce", () => { main: { sessionId: "sid", updatedAt: Date.now(), - lastChannel: "whatsapp", + lastProvider: "whatsapp", lastTo: "+1555", }, }, diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 9f136e3df..316334f0a 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -46,7 +46,7 @@ export type HeartbeatTarget = | "none"; export type HeartbeatDeliveryTarget = { - channel: + provider: | "whatsapp" | "telegram" | "discord" @@ -143,13 +143,13 @@ function resolveHeartbeatReplyPayload( function resolveHeartbeatSender(params: { allowFrom: Array; lastTo?: string; - lastChannel?: SessionEntry["lastChannel"]; + lastProvider?: SessionEntry["lastProvider"]; }) { - const { allowFrom, lastTo, lastChannel } = params; + const { allowFrom, lastTo, lastProvider } = params; const candidates = [ lastTo?.trim(), - lastChannel === "telegram" && lastTo ? `telegram:${lastTo}` : undefined, - lastChannel === "whatsapp" && lastTo ? `whatsapp:${lastTo}` : undefined, + lastProvider === "telegram" && lastTo ? `telegram:${lastTo}` : undefined, + lastProvider === "whatsapp" && lastTo ? `whatsapp:${lastTo}` : undefined, ].filter((val): val is string => Boolean(val?.trim())); const allowList = allowFrom @@ -209,7 +209,7 @@ export function resolveHeartbeatDeliveryTarget(params: { ? rawTarget : "last"; if (target === "none") { - return { channel: "none", reason: "target-none" }; + return { provider: "none", reason: "target-none" }; } const explicitTo = @@ -218,13 +218,13 @@ export function resolveHeartbeatDeliveryTarget(params: { ? cfg.agent.heartbeat.to.trim() : undefined; - const lastChannel = - entry?.lastChannel && entry.lastChannel !== "webchat" - ? entry.lastChannel + const lastProvider = + entry?.lastProvider && entry.lastProvider !== "webchat" + ? entry.lastProvider : undefined; const lastTo = typeof entry?.lastTo === "string" ? entry.lastTo.trim() : ""; - const channel: + const provider: | "whatsapp" | "telegram" | "discord" @@ -233,7 +233,7 @@ export function resolveHeartbeatDeliveryTarget(params: { | "imessage" | undefined = target === "last" - ? lastChannel + ? lastProvider : target === "whatsapp" || target === "telegram" || target === "discord" || @@ -245,27 +245,27 @@ export function resolveHeartbeatDeliveryTarget(params: { const to = explicitTo || - (channel && lastChannel === channel ? lastTo : undefined) || + (provider && lastProvider === provider ? lastTo : undefined) || (target === "last" ? lastTo : undefined); - if (!channel || !to) { - return { channel: "none", reason: "no-target" }; + if (!provider || !to) { + return { provider: "none", reason: "no-target" }; } - if (channel !== "whatsapp") { - return { channel, to }; + if (provider !== "whatsapp") { + return { provider, to }; } const rawAllow = cfg.whatsapp?.allowFrom ?? []; - if (rawAllow.includes("*")) return { channel, to }; + if (rawAllow.includes("*")) return { provider, to }; const allowFrom = rawAllow .map((val) => normalizeE164(val)) .filter((val) => val.length > 1); - if (allowFrom.length === 0) return { channel, to }; + if (allowFrom.length === 0) return { provider, to }; const normalized = normalizeE164(to); - if (allowFrom.includes(normalized)) return { channel, to: normalized }; - return { channel, to: allowFrom[0], reason: "allowFrom-fallback" }; + if (allowFrom.includes(normalized)) return { provider, to: normalized }; + return { provider, to: allowFrom[0], reason: "allowFrom-fallback" }; } async function restoreHeartbeatUpdatedAt(params: { @@ -310,7 +310,7 @@ function normalizeHeartbeatReply( } async function deliverHeartbeatReply(params: { - channel: + provider: | "whatsapp" | "telegram" | "discord" @@ -333,8 +333,8 @@ async function deliverHeartbeatReply(params: { > >; }) { - const { channel, to, text, mediaUrls, deps, textLimit } = params; - if (channel === "whatsapp") { + const { provider, to, text, mediaUrls, deps, textLimit } = params; + if (provider === "whatsapp") { if (mediaUrls.length === 0) { for (const chunk of chunkText(text, textLimit)) { await deps.sendWhatsApp(to, chunk, { verbose: false }); @@ -350,7 +350,7 @@ async function deliverHeartbeatReply(params: { return; } - if (channel === "signal") { + if (provider === "signal") { if (mediaUrls.length === 0) { for (const chunk of chunkText(text, textLimit)) { await deps.sendSignal(to, chunk); @@ -366,7 +366,7 @@ async function deliverHeartbeatReply(params: { return; } - if (channel === "imessage") { + if (provider === "imessage") { if (mediaUrls.length === 0) { for (const chunk of chunkText(text, textLimit)) { await deps.sendIMessage(to, chunk); @@ -382,7 +382,7 @@ async function deliverHeartbeatReply(params: { return; } - if (channel === "telegram") { + if (provider === "telegram") { if (mediaUrls.length === 0) { for (const chunk of chunkText(text, textLimit)) { await deps.sendTelegram(to, chunk, { verbose: false }); @@ -398,7 +398,7 @@ async function deliverHeartbeatReply(params: { return; } - if (channel === "slack") { + if (provider === "slack") { if (mediaUrls.length === 0) { for (const chunk of chunkText(text, textLimit)) { await deps.sendSlack(to, chunk); @@ -413,6 +413,7 @@ async function deliverHeartbeatReply(params: { } return; } + // provider is "discord" here if (mediaUrls.length === 0) { await deps.sendDiscord(to, text, { verbose: false }); return; @@ -450,14 +451,14 @@ export async function runHeartbeatOnce(opts: { const sender = resolveHeartbeatSender({ allowFrom, lastTo: entry?.lastTo, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, }); const prompt = resolveHeartbeatPrompt(cfg); const ctx = { Body: prompt, From: sender, To: sender, - Surface: "heartbeat", + Provider: "heartbeat", }; try { @@ -512,7 +513,7 @@ export async function runHeartbeatOnce(opts: { replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []); - if (delivery.channel === "none" || !delivery.to) { + if (delivery.provider === "none" || !delivery.to) { emitHeartbeatEvent({ status: "skipped", reason: delivery.reason ?? "no-target", @@ -523,7 +524,7 @@ export async function runHeartbeatOnce(opts: { return { status: "ran", durationMs: Date.now() - startedAt }; } - if (delivery.channel === "whatsapp") { + if (delivery.provider === "whatsapp") { const readiness = await resolveWhatsAppReadiness(cfg, opts.deps); if (!readiness.ok) { emitHeartbeatEvent({ @@ -548,9 +549,9 @@ export async function runHeartbeatOnce(opts: { sendSignal: opts.deps?.sendSignal ?? sendMessageSignal, sendIMessage: opts.deps?.sendIMessage ?? sendMessageIMessage, }; - const textLimit = resolveTextChunkLimit(cfg, delivery.channel); + const textLimit = resolveTextChunkLimit(cfg, delivery.provider); await deliverHeartbeatReply({ - channel: delivery.channel, + provider: delivery.provider, to: delivery.to, text: normalized.text, mediaUrls, diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts new file mode 100644 index 000000000..9e18a00cd --- /dev/null +++ b/src/routing/resolve-route.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveAgentRoute } from "./resolve-route.js"; + +describe("resolveAgentRoute", () => { + test("defaults to main/default when no bindings exist", () => { + const cfg: ClawdbotConfig = {}; + const route = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: null, + peer: { kind: "dm", id: "+15551234567" }, + }); + expect(route.agentId).toBe("main"); + expect(route.accountId).toBe("default"); + expect(route.sessionKey).toBe("agent:main:main"); + expect(route.matchedBy).toBe("default"); + }); + + test("peer binding wins over account binding", () => { + const cfg: ClawdbotConfig = { + routing: { + bindings: [ + { + agentId: "a", + match: { + provider: "whatsapp", + accountId: "biz", + peer: { kind: "dm", id: "+1000" }, + }, + }, + { + agentId: "b", + match: { provider: "whatsapp", accountId: "biz" }, + }, + ], + }, + }; + const route = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: "biz", + peer: { kind: "dm", id: "+1000" }, + }); + expect(route.agentId).toBe("a"); + expect(route.sessionKey).toBe("agent:a:main"); + expect(route.matchedBy).toBe("binding.peer"); + }); + + test("discord channel peer binding wins over guild binding", () => { + const cfg: ClawdbotConfig = { + routing: { + bindings: [ + { + agentId: "chan", + match: { + provider: "discord", + accountId: "default", + peer: { kind: "channel", id: "c1" }, + }, + }, + { + agentId: "guild", + match: { + provider: "discord", + accountId: "default", + guildId: "g1", + }, + }, + ], + }, + }; + const route = resolveAgentRoute({ + cfg, + provider: "discord", + accountId: "default", + peer: { kind: "channel", id: "c1" }, + guildId: "g1", + }); + expect(route.agentId).toBe("chan"); + expect(route.sessionKey).toBe("agent:chan:discord:channel:c1"); + expect(route.matchedBy).toBe("binding.peer"); + }); + + test("guild binding wins over account binding when peer not bound", () => { + const cfg: ClawdbotConfig = { + routing: { + bindings: [ + { + agentId: "guild", + match: { + provider: "discord", + accountId: "default", + guildId: "g1", + }, + }, + { + agentId: "acct", + match: { provider: "discord", accountId: "default" }, + }, + ], + }, + }; + const route = resolveAgentRoute({ + cfg, + provider: "discord", + accountId: "default", + peer: { kind: "channel", id: "c1" }, + guildId: "g1", + }); + expect(route.agentId).toBe("guild"); + expect(route.matchedBy).toBe("binding.guild"); + }); + + test("missing accountId in binding matches default account only", () => { + const cfg: ClawdbotConfig = { + routing: { + bindings: [{ agentId: "defaultAcct", match: { provider: "whatsapp" } }], + }, + }; + + const defaultRoute = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: undefined, + peer: { kind: "dm", id: "+1000" }, + }); + expect(defaultRoute.agentId).toBe("defaultAcct"); + expect(defaultRoute.matchedBy).toBe("binding.account"); + + const otherRoute = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: "biz", + peer: { kind: "dm", id: "+1000" }, + }); + expect(otherRoute.agentId).toBe("main"); + }); + + test("accountId=* matches any account as a provider fallback", () => { + const cfg: ClawdbotConfig = { + routing: { + bindings: [ + { + agentId: "any", + match: { provider: "whatsapp", accountId: "*" }, + }, + ], + }, + }; + const route = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: "biz", + peer: { kind: "dm", id: "+1000" }, + }); + expect(route.agentId).toBe("any"); + expect(route.matchedBy).toBe("binding.provider"); + }); + + test("defaultAgentId is used when no binding matches", () => { + const cfg: ClawdbotConfig = { + routing: { + defaultAgentId: "home", + agents: { home: { workspace: "~/clawd-home" } }, + }, + }; + const route = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: "biz", + peer: { kind: "dm", id: "+1000" }, + }); + expect(route.agentId).toBe("home"); + expect(route.sessionKey).toBe("agent:home:main"); + }); +}); diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts new file mode 100644 index 000000000..568ace79e --- /dev/null +++ b/src/routing/resolve-route.ts @@ -0,0 +1,223 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { + buildAgentMainSessionKey, + buildAgentPeerSessionKey, + DEFAULT_ACCOUNT_ID, + DEFAULT_AGENT_ID, + DEFAULT_MAIN_KEY, + normalizeAgentId, +} from "./session-key.js"; + +export type RoutePeerKind = "dm" | "group" | "channel"; + +export type RoutePeer = { + kind: RoutePeerKind; + id: string; +}; + +export type ResolveAgentRouteInput = { + cfg: ClawdbotConfig; + provider: string; + accountId?: string | null; + peer?: RoutePeer | null; + guildId?: string | null; + teamId?: string | null; +}; + +export type ResolvedAgentRoute = { + agentId: string; + provider: string; + accountId: string; + /** Internal session key used for persistence + concurrency. */ + sessionKey: string; + /** Convenience alias for direct-chat collapse. */ + mainSessionKey: string; + /** Match description for debugging/logging. */ + matchedBy: + | "binding.peer" + | "binding.guild" + | "binding.team" + | "binding.account" + | "binding.provider" + | "default"; +}; + +export { DEFAULT_ACCOUNT_ID, DEFAULT_AGENT_ID } from "./session-key.js"; + +function normalizeToken(value: string | undefined | null): string { + return (value ?? "").trim().toLowerCase(); +} + +function normalizeId(value: string | undefined | null): string { + return (value ?? "").trim(); +} + +function normalizeAccountId(value: string | undefined | null): string { + const trimmed = (value ?? "").trim(); + return trimmed ? trimmed : DEFAULT_ACCOUNT_ID; +} + +function matchesAccountId(match: string | undefined, actual: string): boolean { + const trimmed = (match ?? "").trim(); + if (!trimmed) return actual === DEFAULT_ACCOUNT_ID; + if (trimmed === "*") return true; + return trimmed === actual; +} + +export function buildAgentSessionKey(params: { + agentId: string; + provider: string; + peer?: RoutePeer | null; +}): string { + const provider = normalizeToken(params.provider) || "unknown"; + const peer = params.peer; + return buildAgentPeerSessionKey({ + agentId: params.agentId, + mainKey: DEFAULT_MAIN_KEY, + provider, + peerKind: peer?.kind ?? "dm", + peerId: peer ? normalizeId(peer.id) || "unknown" : null, + }); +} + +function listBindings(cfg: ClawdbotConfig) { + const bindings = cfg.routing?.bindings; + return Array.isArray(bindings) ? bindings : []; +} + +function listAgents(cfg: ClawdbotConfig) { + const agents = cfg.routing?.agents; + return agents && typeof agents === "object" ? agents : undefined; +} + +function resolveDefaultAgentId(cfg: ClawdbotConfig): string { + const explicit = cfg.routing?.defaultAgentId?.trim(); + if (explicit) return explicit; + return DEFAULT_AGENT_ID; +} + +function pickFirstExistingAgentId( + cfg: ClawdbotConfig, + agentId: string, +): string { + const normalized = normalizeAgentId(agentId); + const agents = listAgents(cfg); + if (!agents) return normalized; + if (Object.hasOwn(agents, normalized)) return normalized; + return normalizeAgentId(resolveDefaultAgentId(cfg)); +} + +function matchesProvider( + match: { provider?: string | undefined } | undefined, + provider: string, +): boolean { + const key = normalizeToken(match?.provider); + if (!key) return false; + return key === provider; +} + +function matchesPeer( + match: { peer?: { kind?: string; id?: string } | undefined } | undefined, + peer: RoutePeer, +): boolean { + const m = match?.peer; + if (!m) return false; + const kind = normalizeToken(m.kind); + const id = normalizeId(m.id); + if (!kind || !id) return false; + return kind === peer.kind && id === peer.id; +} + +function matchesGuild( + match: { guildId?: string | undefined } | undefined, + guildId: string, +): boolean { + const id = normalizeId(match?.guildId); + if (!id) return false; + return id === guildId; +} + +function matchesTeam( + match: { teamId?: string | undefined } | undefined, + teamId: string, +): boolean { + const id = normalizeId(match?.teamId); + if (!id) return false; + return id === teamId; +} + +export function resolveAgentRoute( + input: ResolveAgentRouteInput, +): ResolvedAgentRoute { + const provider = normalizeToken(input.provider); + const accountId = normalizeAccountId(input.accountId); + const peer = input.peer + ? { kind: input.peer.kind, id: normalizeId(input.peer.id) } + : null; + const guildId = normalizeId(input.guildId); + const teamId = normalizeId(input.teamId); + + const bindings = listBindings(input.cfg).filter((binding) => { + if (!binding || typeof binding !== "object") return false; + if (!matchesProvider(binding.match, provider)) return false; + return matchesAccountId(binding.match?.accountId, accountId); + }); + + const choose = ( + agentId: string, + matchedBy: ResolvedAgentRoute["matchedBy"], + ) => { + const resolvedAgentId = pickFirstExistingAgentId(input.cfg, agentId); + return { + agentId: resolvedAgentId, + provider, + accountId, + sessionKey: buildAgentSessionKey({ + agentId: resolvedAgentId, + provider, + peer, + }), + mainSessionKey: buildAgentMainSessionKey({ + agentId: resolvedAgentId, + mainKey: DEFAULT_MAIN_KEY, + }), + matchedBy, + }; + }; + + if (peer) { + const peerMatch = bindings.find((b) => matchesPeer(b.match, peer)); + if (peerMatch) return choose(peerMatch.agentId, "binding.peer"); + } + + if (guildId) { + const guildMatch = bindings.find((b) => matchesGuild(b.match, guildId)); + if (guildMatch) return choose(guildMatch.agentId, "binding.guild"); + } + + if (teamId) { + const teamMatch = bindings.find((b) => matchesTeam(b.match, teamId)); + if (teamMatch) return choose(teamMatch.agentId, "binding.team"); + } + + const accountMatch = bindings.find( + (b) => + b.match?.accountId?.trim() !== "*" && + !b.match?.peer && + !b.match?.guildId && + !b.match?.teamId, + ); + if (accountMatch) return choose(accountMatch.agentId, "binding.account"); + + const anyAccountMatch = bindings.find( + (b) => + b.match?.accountId?.trim() === "*" && + !b.match?.peer && + !b.match?.guildId && + !b.match?.teamId, + ); + if (anyAccountMatch) + return choose(anyAccountMatch.agentId, "binding.provider"); + + return choose(resolveDefaultAgentId(input.cfg), "default"); +} diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts new file mode 100644 index 000000000..3a4ecc7a7 --- /dev/null +++ b/src/routing/session-key.ts @@ -0,0 +1,77 @@ +export const DEFAULT_AGENT_ID = "main"; +export const DEFAULT_MAIN_KEY = "main"; +export const DEFAULT_ACCOUNT_ID = "default"; + +export type ParsedAgentSessionKey = { + agentId: string; + rest: string; +}; + +export function normalizeAgentId(value: string | undefined | null): string { + const trimmed = (value ?? "").trim(); + if (!trimmed) return DEFAULT_AGENT_ID; + // Keep it path-safe + shell-friendly. + if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed; + // Best-effort fallback: collapse invalid characters to "-" + return ( + trimmed + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") + .slice(0, 64) || DEFAULT_AGENT_ID + ); +} + +export function parseAgentSessionKey( + sessionKey: string | undefined | null, +): ParsedAgentSessionKey | null { + const raw = (sessionKey ?? "").trim(); + if (!raw) return null; + const parts = raw.split(":").filter(Boolean); + if (parts.length < 3) return null; + if (parts[0] !== "agent") return null; + const agentId = parts[1]?.trim(); + const rest = parts.slice(2).join(":"); + if (!agentId || !rest) return null; + return { agentId, rest }; +} + +export function isSubagentSessionKey( + sessionKey: string | undefined | null, +): boolean { + const raw = (sessionKey ?? "").trim(); + if (!raw) return false; + if (raw.toLowerCase().startsWith("subagent:")) return true; + const parsed = parseAgentSessionKey(raw); + return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:")); +} + +export function buildAgentMainSessionKey(params: { + agentId: string; + mainKey?: string | undefined; +}): string { + const agentId = normalizeAgentId(params.agentId); + const mainKey = + (params.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY; + return `agent:${agentId}:${mainKey}`; +} + +export function buildAgentPeerSessionKey(params: { + agentId: string; + mainKey?: string | undefined; + provider: string; + peerKind?: "dm" | "group" | "channel" | null; + peerId?: string | null; +}): string { + const peerKind = params.peerKind ?? "dm"; + if (peerKind === "dm") { + return buildAgentMainSessionKey({ + agentId: params.agentId, + mainKey: params.mainKey, + }); + } + const provider = (params.provider ?? "").trim().toLowerCase() || "unknown"; + const peerId = (params.peerId ?? "").trim() || "unknown"; + return `agent:${normalizeAgentId(params.agentId)}:${provider}:${peerKind}:${peerId}`; +} diff --git a/src/sessions/send-policy.test.ts b/src/sessions/send-policy.test.ts index 7dd99ffda..d6a95eeb8 100644 --- a/src/sessions/send-policy.test.ts +++ b/src/sessions/send-policy.test.ts @@ -21,7 +21,7 @@ describe("resolveSendPolicy", () => { expect(resolveSendPolicy({ cfg, entry })).toBe("deny"); }); - it("rule match by surface + chatType", () => { + it("rule match by provider + chatType", () => { const cfg = { session: { sendPolicy: { @@ -29,7 +29,7 @@ describe("resolveSendPolicy", () => { rules: [ { action: "deny", - match: { surface: "discord", chatType: "group" }, + match: { provider: "discord", chatType: "group" }, }, ], }, @@ -38,7 +38,7 @@ describe("resolveSendPolicy", () => { const entry: SessionEntry = { sessionId: "s", updatedAt: 0, - surface: "discord", + provider: "discord", chatType: "group", }; expect( diff --git a/src/sessions/send-policy.ts b/src/sessions/send-policy.ts index 10b5311fc..b742a6ff3 100644 --- a/src/sessions/send-policy.ts +++ b/src/sessions/send-policy.ts @@ -17,7 +17,7 @@ function normalizeMatchValue(raw?: string | null) { return value ? value : undefined; } -function deriveSurfaceFromKey(key?: string) { +function deriveProviderFromKey(key?: string) { if (!key) return undefined; const parts = key.split(":").filter(Boolean); if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) { @@ -37,7 +37,7 @@ export function resolveSendPolicy(params: { cfg: ClawdbotConfig; entry?: SessionEntry; sessionKey?: string; - surface?: string; + provider?: string; chatType?: SessionChatType; }): SessionSendPolicyDecision { const override = normalizeSendPolicy(params.entry?.sendPolicy); @@ -46,11 +46,11 @@ export function resolveSendPolicy(params: { const policy = params.cfg.session?.sendPolicy; if (!policy) return "allow"; - const surface = - normalizeMatchValue(params.surface) ?? - normalizeMatchValue(params.entry?.surface) ?? - normalizeMatchValue(params.entry?.lastChannel) ?? - deriveSurfaceFromKey(params.sessionKey); + const provider = + normalizeMatchValue(params.provider) ?? + normalizeMatchValue(params.entry?.provider) ?? + normalizeMatchValue(params.entry?.lastProvider) ?? + deriveProviderFromKey(params.sessionKey); const chatType = normalizeMatchValue(params.chatType ?? params.entry?.chatType) ?? normalizeMatchValue(deriveChatTypeFromKey(params.sessionKey)); @@ -61,11 +61,11 @@ export function resolveSendPolicy(params: { if (!rule) continue; const action = normalizeSendPolicy(rule.action) ?? "allow"; const match = rule.match ?? {}; - const matchSurface = normalizeMatchValue(match.surface); + const matchProvider = normalizeMatchValue(match.provider); const matchChatType = normalizeMatchValue(match.chatType); const matchPrefix = normalizeMatchValue(match.keyPrefix); - if (matchSurface && matchSurface !== surface) continue; + if (matchProvider && matchProvider !== provider) continue; if (matchChatType && matchChatType !== chatType) continue; if (matchPrefix && !sessionKey.startsWith(matchPrefix)) continue; if (action === "deny") return "deny"; diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 33bbcef65..8f785f164 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -12,6 +12,7 @@ import { readProviderAllowFromStore, upsertProviderPairingRequest, } from "../pairing/pairing-store.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { normalizeE164 } from "../utils.js"; import { signalCheck, signalRpcRequest, streamSignalEvents } from "./client.js"; @@ -436,21 +437,31 @@ export async function monitorSignalProvider( ? `${groupName ?? "Signal Group"} id:${groupId}` : `${envelope.sourceName ?? sender} id:${sender}`; const body = formatAgentEnvelope({ - surface: "Signal", + provider: "Signal", from: fromLabel, timestamp: envelope.timestamp ?? undefined, body: bodyText, }); + const route = resolveAgentRoute({ + cfg, + provider: "signal", + peer: { + kind: isGroup ? "group" : "dm", + id: isGroup ? (groupId ?? "unknown") : normalizeE164(sender), + }, + }); const ctxPayload = { Body: body, - From: isGroup ? `group:${groupId}` : `signal:${sender}`, - To: isGroup ? `group:${groupId}` : `signal:${sender}`, + From: isGroup ? `group:${groupId ?? "unknown"}` : `signal:${sender}`, + To: isGroup ? `group:${groupId ?? "unknown"}` : `signal:${sender}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, ChatType: isGroup ? "group" : "direct", GroupSubject: isGroup ? (groupName ?? undefined) : undefined, SenderName: envelope.sourceName ?? sender, SenderId: sender, - Surface: "signal" as const, + Provider: "signal" as const, MessageSid: envelope.timestamp ? String(envelope.timestamp) : undefined, Timestamp: envelope.timestamp ?? undefined, MediaPath: mediaPath, @@ -461,13 +472,15 @@ export async function monitorSignalProvider( if (!isGroup) { const sessionCfg = cfg.session; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = resolveStorePath(sessionCfg?.store, { + agentId: route.agentId, + }); await updateLastRoute({ storePath, - sessionKey: mainKey, - channel: "signal", + sessionKey: route.mainSessionKey, + provider: "signal", to: normalizeE164(sender), + accountId: route.accountId, }); } diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index d194f2b2a..ebe5eebbc 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -34,6 +34,7 @@ import { readProviderAllowFromStore, upsertProviderPairingRequest, } from "../pairing/pairing-store.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { reactSlackMessage } from "./actions.js"; import { sendMessageSlack } from "./send.js"; @@ -347,7 +348,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const chatType = isRoom ? "room" : isGroup ? "group" : "direct"; return resolveSessionKey( sessionScope, - { From: from, ChatType: chatType, Surface: "slack" }, + { From: from, ChatType: chatType, Provider: "slack" }, mainKey, ); }; @@ -430,9 +431,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }); let botUserId = ""; + let teamId = ""; try { const auth = await app.client.auth.test({ token: botToken }); botUserId = auth.user_id ?? ""; + teamId = auth.team_id ?? ""; } catch (err) { runtime.error?.(danger(`slack auth failed: ${String(err)}`)); } @@ -731,15 +734,16 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { : isRoom ? `slack:channel:${message.channel}` : `slack:group:${message.channel}`; - const sessionKey = resolveSessionKey( - sessionScope, - { - From: slackFrom, - ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", - Surface: "slack", + const route = resolveAgentRoute({ + cfg, + provider: "slack", + teamId: teamId || undefined, + peer: { + kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group", + id: isDirectMessage ? (message.user ?? "unknown") : message.channel, }, - mainKey, - ); + }); + const sessionKey = route.sessionKey; enqueueSystemEvent(`${inboundLabel}: ${preview}`, { sessionKey, contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`, @@ -747,7 +751,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`; const body = formatAgentEnvelope({ - surface: "Slack", + provider: "Slack", from: senderName, timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, body: textWithId, @@ -760,11 +764,13 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { To: isDirectMessage ? `user:${message.user}` : `channel:${message.channel}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", GroupSubject: isRoomish ? roomLabel : undefined, SenderName: senderName, SenderId: message.user, - Surface: "slack" as const, + Provider: "slack" as const, MessageSid: message.ts, ReplyToId: message.thread_ts ?? message.ts, Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, @@ -783,13 +789,15 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { if (isDirectMessage) { const sessionCfg = cfg.session; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = resolveStorePath(sessionCfg?.store, { + agentId: route.agentId, + }); await updateLastRoute({ storePath, - sessionKey: mainKey, - channel: "slack", + sessionKey: route.mainSessionKey, + provider: "slack", to: `user:${message.user}`, + accountId: route.accountId, }); } @@ -1427,6 +1435,12 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { ? `#${channelName}` : `#${command.channel_id}`; const isRoomish = isRoom || isGroupDm; + const route = resolveAgentRoute({ + cfg, + provider: "slack", + teamId: teamId || undefined, + peer: { kind: "dm", id: command.user_id }, + }); const ctxPayload = { Body: prompt, @@ -1439,11 +1453,12 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", GroupSubject: isRoomish ? roomLabel : undefined, SenderName: senderName, - Surface: "slack" as const, + Provider: "slack" as const, WasMentioned: true, MessageSid: command.trigger_id, Timestamp: Date.now(), - SessionKey: `${slashCommand.sessionPrefix}:${command.user_id}`, + SessionKey: `agent:${route.agentId}:${slashCommand.sessionPrefix}:${command.user_id}`, + AccountId: route.accountId, }; const replyResult = await getReplyFromConfig( diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index b7e4ff5b8..8e7cc0799 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -33,6 +33,7 @@ import { type NormalizedLocation, toLocationContext, } from "../providers/location.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { loadWebMedia } from "../web/media.js"; import { @@ -165,13 +166,13 @@ export function createTelegramBot(opts: TelegramBotOptions) { const resolveGroupPolicy = (chatId: string | number) => resolveProviderGroupPolicy({ cfg, - surface: "telegram", + provider: "telegram", groupId: String(chatId), }); const resolveGroupRequireMention = (chatId: string | number) => resolveProviderGroupRequireMention({ cfg, - surface: "telegram", + provider: "telegram", groupId: String(chatId), requireMentionOverride: opts.requireMention, overrideOrder: "after-config", @@ -363,7 +364,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { }]\n${replyTarget.body}\n[/Replying]` : ""; const body = formatAgentEnvelope({ - surface: "Telegram", + provider: "Telegram", from: isGroup ? buildGroupLabel(msg, chatId) : buildSenderLabel(msg, chatId), @@ -371,16 +372,26 @@ export function createTelegramBot(opts: TelegramBotOptions) { body: `${bodyText}${replySuffix}`, }); + const route = resolveAgentRoute({ + cfg, + provider: "telegram", + peer: { + kind: isGroup ? "group" : "dm", + id: String(chatId), + }, + }); const ctxPayload = { Body: body, From: isGroup ? `group:${chatId}` : `telegram:${chatId}`, To: `telegram:${chatId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, ChatType: isGroup ? "group" : "direct", GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, SenderName: buildSenderName(msg), SenderId: senderId || undefined, SenderUsername: senderUsername || undefined, - Surface: "telegram", + Provider: "telegram", MessageSid: String(msg.message_id), ReplyToId: replyTarget?.id, ReplyToBody: replyTarget?.body, @@ -409,13 +420,15 @@ export function createTelegramBot(opts: TelegramBotOptions) { if (!isGroup) { const sessionCfg = cfg.session; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = resolveStorePath(sessionCfg?.store, { + agentId: route.agentId, + }); await updateLastRoute({ storePath, - sessionKey: mainKey, - channel: "telegram", + sessionKey: route.mainSessionKey, + provider: "telegram", to: String(chatId), + accountId: route.accountId, }); } diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index b55af013a..1fe903050 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -45,13 +45,14 @@ export type GatewaySessionList = { contextTokens?: number | null; totalTokens?: number | null; displayName?: string; - surface?: string; + provider?: string; room?: string; space?: string; subject?: string; chatType?: string; - lastChannel?: string; + lastProvider?: string; lastTo?: string; + lastAccountId?: string; }>; }; diff --git a/src/web/accounts.ts b/src/web/accounts.ts new file mode 100644 index 000000000..a9fcffaad --- /dev/null +++ b/src/web/accounts.ts @@ -0,0 +1,122 @@ +import fs from "node:fs"; +import path from "node:path"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveOAuthDir } from "../config/paths.js"; +import type { GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; +import { resolveUserPath } from "../utils.js"; + +export type ResolvedWhatsAppAccount = { + accountId: string; + enabled: boolean; + authDir: string; + isLegacyAuthDir: boolean; + allowFrom?: string[]; + groupAllowFrom?: string[]; + groupPolicy?: GroupPolicy; + textChunkLimit?: number; + groups?: WhatsAppAccountConfig["groups"]; +}; + +function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] { + const accounts = cfg.whatsapp?.accounts; + if (!accounts || typeof accounts !== "object") return []; + return Object.keys(accounts).filter(Boolean); +} + +export function listWhatsAppAccountIds(cfg: ClawdbotConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; + return ids.sort((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultWhatsAppAccountId(cfg: ClawdbotConfig): string { + const ids = listWhatsAppAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): WhatsAppAccountConfig | undefined { + const accounts = cfg.whatsapp?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + const entry = accounts[accountId] as WhatsAppAccountConfig | undefined; + return entry; +} + +function resolveDefaultAuthDir(accountId: string): string { + return path.join(resolveOAuthDir(), "whatsapp", accountId); +} + +function resolveLegacyAuthDir(): string { + // Legacy Baileys creds lived in the same directory as OAuth tokens. + return resolveOAuthDir(); +} + +function legacyAuthExists(authDir: string): boolean { + try { + return fs.existsSync(path.join(authDir, "creds.json")); + } catch { + return false; + } +} + +export function resolveWhatsAppAuthDir(params: { + cfg: ClawdbotConfig; + accountId: string; +}): { authDir: string; isLegacy: boolean } { + const accountId = params.accountId.trim() || DEFAULT_ACCOUNT_ID; + const account = resolveAccountConfig(params.cfg, accountId); + const configured = account?.authDir?.trim(); + if (configured) { + return { authDir: resolveUserPath(configured), isLegacy: false }; + } + + const defaultDir = resolveDefaultAuthDir(accountId); + if (accountId === DEFAULT_ACCOUNT_ID) { + const legacyDir = resolveLegacyAuthDir(); + if (legacyAuthExists(legacyDir) && !legacyAuthExists(defaultDir)) { + return { authDir: legacyDir, isLegacy: true }; + } + } + + return { authDir: defaultDir, isLegacy: false }; +} + +export function resolveWhatsAppAccount(params: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): ResolvedWhatsAppAccount { + const accountId = + params.accountId?.trim() || resolveDefaultWhatsAppAccountId(params.cfg); + const accountCfg = resolveAccountConfig(params.cfg, accountId); + const enabled = accountCfg?.enabled !== false; + const { authDir, isLegacy } = resolveWhatsAppAuthDir({ + cfg: params.cfg, + accountId, + }); + return { + accountId, + enabled, + authDir, + isLegacyAuthDir: isLegacy, + allowFrom: accountCfg?.allowFrom ?? params.cfg.whatsapp?.allowFrom, + groupAllowFrom: + accountCfg?.groupAllowFrom ?? params.cfg.whatsapp?.groupAllowFrom, + groupPolicy: accountCfg?.groupPolicy ?? params.cfg.whatsapp?.groupPolicy, + textChunkLimit: + accountCfg?.textChunkLimit ?? params.cfg.whatsapp?.textChunkLimit, + groups: accountCfg?.groups ?? params.cfg.whatsapp?.groups, + }; +} + +export function listEnabledWhatsAppAccounts( + cfg: ClawdbotConfig, +): ResolvedWhatsAppAccount[] { + return listWhatsAppAccountIds(cfg) + .map((accountId) => resolveWhatsAppAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/src/web/active-listener.ts b/src/web/active-listener.ts index 6c9fc41a6..76c7016db 100644 --- a/src/web/active-listener.ts +++ b/src/web/active-listener.ts @@ -1,4 +1,5 @@ import type { PollInput } from "../polls.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export type ActiveWebSendOptions = { gifPlayback?: boolean; @@ -17,12 +18,41 @@ export type ActiveWebListener = { close?: () => Promise; }; -let currentListener: ActiveWebListener | null = null; +let _currentListener: ActiveWebListener | null = null; -export function setActiveWebListener(listener: ActiveWebListener | null) { - currentListener = listener; +const listeners = new Map(); + +export function setActiveWebListener(listener: ActiveWebListener | null): void; +export function setActiveWebListener( + accountId: string | null | undefined, + listener: ActiveWebListener | null, +): void; +export function setActiveWebListener( + accountIdOrListener: string | ActiveWebListener | null | undefined, + maybeListener?: ActiveWebListener | null, +): void { + const { accountId, listener } = + typeof accountIdOrListener === "string" + ? { accountId: accountIdOrListener, listener: maybeListener ?? null } + : { + accountId: DEFAULT_ACCOUNT_ID, + listener: accountIdOrListener ?? null, + }; + + const id = (accountId ?? "").trim() || DEFAULT_ACCOUNT_ID; + if (!listener) { + listeners.delete(id); + } else { + listeners.set(id, listener); + } + if (id === DEFAULT_ACCOUNT_ID) { + _currentListener = listener; + } } -export function getActiveWebListener(): ActiveWebListener | null { - return currentListener; +export function getActiveWebListener( + accountId?: string | null, +): ActiveWebListener | null { + const id = (accountId ?? "").trim() || DEFAULT_ACCOUNT_ID; + return listeners.get(id) ?? null; } diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 3b3525cbd..4cf7ec565 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -105,7 +105,7 @@ const makeSessionStore = async ( }; describe("partial reply gating", () => { - it("does not send partial replies for WhatsApp surface", async () => { + it("does not send partial replies for WhatsApp provider", async () => { const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn().mockResolvedValue(undefined); const sendMedia = vi.fn().mockResolvedValue(undefined); @@ -153,8 +153,9 @@ describe("partial reply gating", () => { it("updates last-route for direct chats without senderE164", async () => { const now = Date.now(); + const mainSessionKey = "agent:main:main"; const store = await makeSessionStore({ - main: { sessionId: "sid", updatedAt: now - 1 }, + [mainSessionKey]: { sessionId: "sid", updatedAt: now - 1 }, }); const replyResolver = vi.fn().mockResolvedValue(undefined); @@ -163,7 +164,7 @@ describe("partial reply gating", () => { whatsapp: { allowFrom: ["*"], }, - session: { store: store.storePath, mainKey: "main" }, + session: { store: store.storePath }, }; setLoadConfigMock(mockConfig); @@ -190,18 +191,95 @@ describe("partial reply gating", () => { replyResolver, ); - let stored: { main?: { lastChannel?: string; lastTo?: string } } | null = - null; + let stored: Record< + string, + { lastProvider?: string; lastTo?: string } + > | null = null; for (let attempt = 0; attempt < 50; attempt += 1) { - stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as { - main?: { lastChannel?: string; lastTo?: string }; - }; - if (stored.main?.lastChannel && stored.main?.lastTo) break; + stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record< + string, + { lastProvider?: string; lastTo?: string } + >; + if ( + stored[mainSessionKey]?.lastProvider && + stored[mainSessionKey]?.lastTo + ) + break; await new Promise((resolve) => setTimeout(resolve, 5)); } if (!stored) throw new Error("store not loaded"); - expect(stored.main?.lastChannel).toBe("whatsapp"); - expect(stored.main?.lastTo).toBe("+1000"); + expect(stored[mainSessionKey]?.lastProvider).toBe("whatsapp"); + expect(stored[mainSessionKey]?.lastTo).toBe("+1000"); + + resetLoadConfigMock(); + await store.cleanup(); + }); + + it("updates last-route for group chats with account id", async () => { + const now = Date.now(); + const groupSessionKey = "agent:main:whatsapp:group:123@g.us"; + const store = await makeSessionStore({ + [groupSessionKey]: { sessionId: "sid", updatedAt: now - 1 }, + }); + + const replyResolver = vi.fn().mockResolvedValue(undefined); + + const mockConfig: ClawdbotConfig = { + whatsapp: { + allowFrom: ["*"], + }, + session: { store: store.storePath }, + }; + + setLoadConfigMock(mockConfig); + + await monitorWebProvider( + false, + async ({ onMessage }) => { + await onMessage({ + id: "g1", + from: "123@g.us", + conversationId: "123@g.us", + to: "+2000", + body: "hello", + timestamp: now, + chatType: "group", + chatId: "123@g.us", + accountId: "work", + senderE164: "+1000", + senderName: "Alice", + selfE164: "+2000", + sendComposing: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + sendMedia: vi.fn().mockResolvedValue(undefined), + }); + return { close: vi.fn().mockResolvedValue(undefined) }; + }, + false, + replyResolver, + ); + + let stored: Record< + string, + { lastProvider?: string; lastTo?: string; lastAccountId?: string } + > | null = null; + for (let attempt = 0; attempt < 50; attempt += 1) { + stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record< + string, + { lastProvider?: string; lastTo?: string; lastAccountId?: string } + >; + if ( + stored[groupSessionKey]?.lastProvider && + stored[groupSessionKey]?.lastTo && + stored[groupSessionKey]?.lastAccountId + ) + break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + if (!stored) throw new Error("store not loaded"); + expect(stored[groupSessionKey]?.lastProvider).toBe("whatsapp"); + expect(stored[groupSessionKey]?.lastTo).toBe("123@g.us"); + expect(stored[groupSessionKey]?.lastAccountId).toBe("work"); resetLoadConfigMock(); await store.cleanup(); @@ -1215,7 +1293,7 @@ describe("web auto-reply", () => { .mockResolvedValueOnce({ text: "ok" }); const { storePath, cleanup } = await makeSessionStore({ - "whatsapp:group:123@g.us": { + "agent:main:whatsapp:group:123@g.us": { sessionId: "g-1", updatedAt: Date.now(), groupActivation: "always", diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 86d65cfce..ee1034bbe 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -40,8 +40,10 @@ import { enqueueSystemEvent } from "../infra/system-events.js"; import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { createSubsystemLogger, getChildLogger } from "../logging.js"; import { toLocationContext } from "../providers/location.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js"; +import { resolveWhatsAppAccount } from "./accounts.js"; import { setActiveWebListener } from "./active-listener.js"; import { monitorWebInbox } from "./inbound.js"; import { loadWebMedia } from "./media.js"; @@ -123,6 +125,8 @@ export type WebMonitorTuning = { heartbeatSeconds?: number; sleep?: (ms: number, signal?: AbortSignal) => Promise; statusSink?: (status: WebProviderStatus) => void; + /** WhatsApp account id. Default: "default". */ + accountId?: string; }; const formatDuration = (ms: number) => @@ -458,7 +462,7 @@ function getSessionRecipients(cfg: ReturnType) { .filter(([key]) => !isGroupKey(key) && !isCronKey(key)) .map(([_, entry]) => ({ to: - entry?.lastChannel === "whatsapp" && entry?.lastTo + entry?.lastProvider === "whatsapp" && entry?.lastTo ? normalizeE164(entry.lastTo) : "", updatedAt: entry?.updatedAt ?? 0, @@ -762,7 +766,22 @@ export async function monitorWebProvider( }); }; emitStatus(); - const cfg = loadConfig(); + const baseCfg = loadConfig(); + const account = resolveWhatsAppAccount({ + cfg: baseCfg, + accountId: tuning.accountId, + }); + const cfg = { + ...baseCfg, + whatsapp: { + ...baseCfg.whatsapp, + allowFrom: account.allowFrom, + groupAllowFrom: account.groupAllowFrom, + groupPolicy: account.groupPolicy, + textChunkLimit: account.textChunkLimit, + groups: account.groups, + }, + } satisfies ReturnType; const configuredMaxMb = cfg.agent?.mediaMaxMb; const maxMediaBytes = typeof configuredMaxMb === "number" && configuredMaxMb > 0 @@ -774,7 +793,6 @@ export async function monitorWebProvider( ); const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect); const mentionConfig = buildMentionConfig(cfg); - const sessionStorePath = resolveStorePath(cfg.session?.store); const groupHistoryLimit = cfg.routing?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT; const groupHistories = new Map< @@ -853,7 +871,7 @@ export async function monitorWebProvider( resolveGroupSessionKey({ From: conversationId, ChatType: "group", - Surface: "whatsapp", + Provider: "whatsapp", }); const resolveGroupPolicyFor = (conversationId: string) => { @@ -861,7 +879,7 @@ export async function monitorWebProvider( resolveGroupResolution(conversationId)?.id ?? conversationId; return resolveProviderGroupPolicy({ cfg, - surface: "whatsapp", + provider: "whatsapp", groupId, }); }; @@ -871,20 +889,22 @@ export async function monitorWebProvider( resolveGroupResolution(conversationId)?.id ?? conversationId; return resolveProviderGroupRequireMention({ cfg, - surface: "whatsapp", + provider: "whatsapp", groupId, }); }; - const resolveGroupActivationFor = (conversationId: string) => { - const key = - resolveGroupResolution(conversationId)?.key ?? - (conversationId.startsWith("group:") - ? conversationId - : `whatsapp:group:${conversationId}`); - const store = loadSessionStore(sessionStorePath); - const entry = store[key]; - const requireMention = resolveGroupRequireMentionFor(conversationId); + const resolveGroupActivationFor = (params: { + agentId: string; + sessionKey: string; + conversationId: string; + }) => { + const storePath = resolveStorePath(cfg.session?.store, { + agentId: params.agentId, + }); + const store = loadSessionStore(storePath); + const entry = store[params.sessionKey]; + const requireMention = resolveGroupRequireMentionFor(params.conversationId); const defaultActivation = requireMention === false ? "always" : "mention"; return ( normalizeGroupActivation(entry?.groupActivation) ?? defaultActivation @@ -1020,7 +1040,7 @@ export async function monitorWebProvider( // Wrap with standardized envelope for the agent. return formatAgentEnvelope({ - surface: "WhatsApp", + provider: "WhatsApp", from: msg.chatType === "group" ? msg.from @@ -1030,7 +1050,10 @@ export async function monitorWebProvider( }); }; - const processMessage = async (msg: WebInboundMsg) => { + const processMessage = async ( + msg: WebInboundMsg, + route: ReturnType, + ) => { status.lastMessageAt = Date.now(); status.lastEventAt = status.lastMessageAt; emitStatus(); @@ -1039,14 +1062,14 @@ export async function monitorWebProvider( let shouldClearGroupHistory = false; if (msg.chatType === "group") { - const history = groupHistories.get(conversationId) ?? []; + const history = groupHistories.get(route.sessionKey) ?? []; const historyWithoutCurrent = history.length > 0 ? history.slice(0, -1) : []; if (historyWithoutCurrent.length > 0) { const historyText = historyWithoutCurrent .map((m) => formatAgentEnvelope({ - surface: "WhatsApp", + provider: "WhatsApp", from: conversationId, timestamp: m.timestamp, body: `${m.sender}: ${m.body}`, @@ -1096,8 +1119,9 @@ export async function monitorWebProvider( if (msg.chatType !== "group") { const sessionCfg = cfg.session; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = resolveStorePath(sessionCfg?.store, { + agentId: route.agentId, + }); const to = (() => { if (msg.senderE164) return normalizeE164(msg.senderE164); // In direct chats, `msg.from` is already the canonical conversation id, @@ -1109,12 +1133,18 @@ export async function monitorWebProvider( if (to) { const task = updateLastRoute({ storePath, - sessionKey: mainKey, - channel: "whatsapp", + sessionKey: route.mainSessionKey, + provider: "whatsapp", to, + accountId: route.accountId, }).catch((err) => { replyLogger.warn( - { error: formatError(err), storePath, sessionKey: mainKey, to }, + { + error: formatError(err), + storePath, + sessionKey: route.mainSessionKey, + to, + }, "failed updating last route", ); }); @@ -1200,6 +1230,8 @@ export async function monitorWebProvider( Body: combinedBody, From: msg.from, To: msg.to, + SessionKey: route.sessionKey, + AccountId: route.accountId, MessageSid: msg.id, ReplyToId: msg.replyToId, ReplyToBody: msg.replyToBody, @@ -1211,14 +1243,14 @@ export async function monitorWebProvider( GroupSubject: msg.groupSubject, GroupMembers: formatGroupMembers( msg.groupParticipants, - groupMemberNames.get(conversationId), + groupMemberNames.get(route.sessionKey), msg.senderE164, ), SenderName: msg.senderName, SenderE164: msg.senderE164, WasMentioned: msg.wasMentioned, ...(msg.location ? toLocationContext(msg.location) : {}), - Surface: "whatsapp", + Provider: "whatsapp", }, cfg, dispatcher, @@ -1233,7 +1265,7 @@ export async function monitorWebProvider( typingController?.markDispatchIdle(); if (!queuedFinal) { if (shouldClearGroupHistory && didSendReply) { - groupHistories.set(conversationId, []); + groupHistories.set(route.sessionKey, []); } logVerbose( "Skipping auto-reply: silent token or no text/media returned from resolver", @@ -1242,12 +1274,14 @@ export async function monitorWebProvider( } if (shouldClearGroupHistory && didSendReply) { - groupHistories.set(conversationId, []); + groupHistories.set(route.sessionKey, []); } }; const listener = await (listenerFactory ?? monitorWebInbox)({ verbose, + accountId: account.accountId, + authDir: account.authDir, onMessage: async (msg) => { handledMessages += 1; lastMessageAt = Date.now(); @@ -1256,6 +1290,28 @@ export async function monitorWebProvider( emitStatus(); _lastInboundMsg = msg; const conversationId = msg.conversationId ?? msg.from; + const peerId = + msg.chatType === "group" + ? conversationId + : (() => { + if (msg.senderE164) { + return normalizeE164(msg.senderE164) ?? msg.senderE164; + } + if (msg.from.includes("@")) { + return jidToE164(msg.from) ?? msg.from; + } + return normalizeE164(msg.from) ?? msg.from; + })(); + const route = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: msg.accountId, + peer: { + kind: msg.chatType === "group" ? "group" : "dm", + id: peerId, + }, + }); + const groupHistoryKey = route.sessionKey; // Same-phone mode logging retained if (msg.from === msg.to) { @@ -1282,7 +1338,33 @@ export async function monitorWebProvider( ); return; } - noteGroupMember(conversationId, msg.senderE164, msg.senderName); + { + const storePath = resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const task = updateLastRoute({ + storePath, + sessionKey: route.sessionKey, + provider: "whatsapp", + to: conversationId, + accountId: route.accountId, + }).catch((err) => { + replyLogger.warn( + { + error: formatError(err), + storePath, + sessionKey: route.sessionKey, + to: conversationId, + }, + "failed updating last route", + ); + }); + backgroundTasks.add(task); + void task.finally(() => { + backgroundTasks.delete(task); + }); + } + noteGroupMember(groupHistoryKey, msg.senderE164, msg.senderName); const commandBody = stripMentionsForCommand(msg.body, msg.selfE164); const activationCommand = parseActivationCommand(commandBody); const isOwner = isOwnerSender(msg); @@ -1299,7 +1381,7 @@ export async function monitorWebProvider( if (!shouldBypassMention) { const history = - groupHistories.get(conversationId) ?? + groupHistories.get(groupHistoryKey) ?? ([] as Array<{ sender: string; body: string; @@ -1311,7 +1393,7 @@ export async function monitorWebProvider( timestamp: msg.timestamp, }); while (history.length > groupHistoryLimit) history.shift(); - groupHistories.set(conversationId, history); + groupHistories.set(groupHistoryKey, history); } const mentionDebug = debugMention(msg, mentionConfig); @@ -1325,7 +1407,11 @@ export async function monitorWebProvider( ); const wasMentioned = mentionDebug.wasMentioned; msg.wasMentioned = wasMentioned; - const activation = resolveGroupActivationFor(conversationId); + const activation = resolveGroupActivationFor({ + agentId: route.agentId, + sessionKey: route.sessionKey, + conversationId, + }); const requireMention = activation !== "always"; if (!shouldBypassMention && requireMention && !wasMentioned) { logVerbose( @@ -1335,7 +1421,7 @@ export async function monitorWebProvider( } } - return processMessage(msg); + return processMessage(msg, route); }, }); @@ -1346,12 +1432,18 @@ export async function monitorWebProvider( emitStatus(); // Surface a concise connection event for the next main-session turn/heartbeat. - const { e164: selfE164 } = readWebSelfId(); + const { e164: selfE164 } = readWebSelfId(account.authDir); + const connectRoute = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: account.accountId, + }); enqueueSystemEvent( `WhatsApp gateway connected${selfE164 ? ` as ${selfE164}` : ""}.`, + { sessionKey: connectRoute.sessionKey }, ); - setActiveWebListener(listener); + setActiveWebListener(account.accountId, listener); unregisterUnhandled = registerUnhandledRejectionHandler((reason) => { if (!isLikelyWhatsAppCryptoError(reason)) return false; const errorStr = formatError(reason); @@ -1368,7 +1460,7 @@ export async function monitorWebProvider( }); const closeListener = async () => { - setActiveWebListener(null); + setActiveWebListener(account.accountId, null); if (unregisterUnhandled) { unregisterUnhandled(); unregisterUnhandled = null; @@ -1388,7 +1480,7 @@ export async function monitorWebProvider( if (keepAlive) { heartbeat = setInterval(() => { - const authAgeMs = getWebAuthAgeMs(); + const authAgeMs = getWebAuthAgeMs(account.authDir); const minutesSinceLastMessage = lastMessageAt ? Math.floor((Date.now() - lastMessageAt) / 60000) : null; diff --git a/src/web/inbound.ts b/src/web/inbound.ts index 04c445bf3..39b654e53 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -30,6 +30,7 @@ import { normalizeE164, toWhatsappJid, } from "../utils.js"; +import { resolveWhatsAppAccount } from "./accounts.js"; import type { ActiveWebSendOptions } from "./active-listener.js"; import { createWaSocket, @@ -48,6 +49,7 @@ export type WebInboundMessage = { from: string; // conversation id: E.164 for direct chats, group JID for groups conversationId: string; // alias for clarity (same as from) to: string; + accountId: string; body: string; pushName?: string; timestamp?: number; @@ -76,13 +78,17 @@ export type WebInboundMessage = { export async function monitorWebInbox(options: { verbose: boolean; + accountId: string; + authDir: string; onMessage: (msg: WebInboundMessage) => Promise; }) { const inboundLogger = getChildLogger({ module: "web-inbound" }); const inboundConsoleLog = createSubsystemLogger( "gateway/providers/whatsapp", ).child("inbound"); - const sock = await createWaSocket(false, options.verbose); + const sock = await createWaSocket(false, options.verbose, { + authDir: options.authDir, + }); await waitForWaConnection(sock); let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null; const onClose = new Promise((resolve) => { @@ -172,16 +178,29 @@ export async function monitorWebInbox(options: { // Filter unauthorized senders early to prevent wasted processing // and potential session corruption from Bad MAC errors const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ + cfg, + accountId: options.accountId, + }); const dmPolicy = cfg.whatsapp?.dmPolicy ?? "pairing"; - const configuredAllowFrom = cfg.whatsapp?.allowFrom; + const configuredAllowFrom = account.allowFrom; const storeAllowFrom = await readProviderAllowFromStore("whatsapp").catch( () => [], ); - const allowFrom = Array.from( + // Without user config, default to self-only DM access so the owner can talk to themselves + const combinedAllowFrom = Array.from( new Set([...(configuredAllowFrom ?? []), ...storeAllowFrom]), ); + const defaultAllowFrom = + combinedAllowFrom.length === 0 && selfE164 + ? [selfE164] + : undefined; + const allowFrom = + combinedAllowFrom.length > 0 + ? combinedAllowFrom + : defaultAllowFrom; const groupAllowFrom = - cfg.whatsapp?.groupAllowFrom ?? + account.groupAllowFrom ?? (configuredAllowFrom && configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined); @@ -204,7 +223,7 @@ export async function monitorWebInbox(options: { // - "open" (default): groups bypass allowFrom, only mention-gating applies // - "disabled": block all group messages entirely // - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom - const groupPolicy = cfg.whatsapp?.groupPolicy ?? "open"; + const groupPolicy = account.groupPolicy ?? "open"; if (group && groupPolicy === "disabled") { logVerbose(`Blocked group message (groupPolicy: disabled)`); continue; @@ -370,6 +389,7 @@ export async function monitorWebInbox(options: { from, conversationId: from, to: selfE164 ?? "me", + accountId: account.accountId, body, pushName: senderName, timestamp, diff --git a/src/web/login-qr.ts b/src/web/login-qr.ts index 5c70b79e2..60cde83ad 100644 --- a/src/web/login-qr.ts +++ b/src/web/login-qr.ts @@ -1,10 +1,11 @@ import { randomUUID } from "node:crypto"; import { DisconnectReason } from "@whiskeysockets/baileys"; - +import { loadConfig } from "../config/config.js"; import { danger, info, success } from "../globals.js"; import { logInfo } from "../logger.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { resolveWhatsAppAccount } from "./accounts.js"; import { renderQrPngBase64 } from "./qr-image.js"; import { createWaSocket, @@ -19,6 +20,9 @@ import { type WaSocket = Awaited>; type ActiveLogin = { + accountId: string; + authDir: string; + isLegacyAuthDir: boolean; id: string; sock: WaSocket; startedAt: number; @@ -33,7 +37,7 @@ type ActiveLogin = { }; const ACTIVE_LOGIN_TTL_MS = 3 * 60_000; -let activeLogin: ActiveLogin | null = null; +const activeLogins = new Map(); function closeSocket(sock: WaSocket) { try { @@ -43,10 +47,11 @@ function closeSocket(sock: WaSocket) { } } -async function resetActiveLogin(reason?: string) { - if (activeLogin) { - closeSocket(activeLogin.sock); - activeLogin = null; +async function resetActiveLogin(accountId: string, reason?: string) { + const login = activeLogins.get(accountId); + if (login) { + closeSocket(login.sock); + activeLogins.delete(accountId); } if (reason) { logInfo(reason); @@ -57,18 +62,17 @@ function isLoginFresh(login: ActiveLogin) { return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS; } -function attachLoginWaiter(login: ActiveLogin) { +function attachLoginWaiter(accountId: string, login: ActiveLogin) { login.waitPromise = waitForWaConnection(login.sock) .then(() => { - if (activeLogin?.id === login.id) { - activeLogin.connected = true; - } + const current = activeLogins.get(accountId); + if (current?.id === login.id) current.connected = true; }) .catch((err) => { - if (activeLogin?.id === login.id) { - activeLogin.error = formatError(err); - activeLogin.errorStatus = getStatusCode(err); - } + const current = activeLogins.get(accountId); + if (current?.id !== login.id) return; + current.error = formatError(err); + current.errorStatus = getStatusCode(err); }); } @@ -82,12 +86,14 @@ async function restartLoginSocket(login: ActiveLogin, runtime: RuntimeEnv) { ); closeSocket(login.sock); try { - const sock = await createWaSocket(false, login.verbose); + const sock = await createWaSocket(false, login.verbose, { + authDir: login.authDir, + }); login.sock = sock; login.connected = false; login.error = undefined; login.errorStatus = undefined; - attachLoginWaiter(login); + attachLoginWaiter(login.accountId, login); return true; } catch (err) { login.error = formatError(err); @@ -101,12 +107,15 @@ export async function startWebLoginWithQr( verbose?: boolean; timeoutMs?: number; force?: boolean; + accountId?: string; runtime?: RuntimeEnv; } = {}, ): Promise<{ qrDataUrl?: string; message: string }> { const runtime = opts.runtime ?? defaultRuntime; - const hasWeb = await webAuthExists(); - const selfId = readWebSelfId(); + const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId }); + const hasWeb = await webAuthExists(account.authDir); + const selfId = readWebSelfId(account.authDir); if (hasWeb && !opts.force) { const who = selfId.e164 ?? selfId.jid ?? "unknown"; return { @@ -114,14 +123,15 @@ export async function startWebLoginWithQr( }; } - if (activeLogin && isLoginFresh(activeLogin) && activeLogin.qrDataUrl) { + const existing = activeLogins.get(account.accountId); + if (existing && isLoginFresh(existing) && existing.qrDataUrl) { return { - qrDataUrl: activeLogin.qrDataUrl, + qrDataUrl: existing.qrDataUrl, message: "QR already active. Scan it in WhatsApp → Linked Devices.", }; } - await resetActiveLogin(); + await resetActiveLogin(account.accountId); let resolveQr: ((qr: string) => void) | null = null; let rejectQr: ((err: Error) => void) | null = null; @@ -138,11 +148,15 @@ export async function startWebLoginWithQr( ); let sock: WaSocket; + let pendingQr: string | null = null; try { sock = await createWaSocket(false, Boolean(opts.verbose), { + authDir: account.authDir, onQr: (qr: string) => { - if (!activeLogin || activeLogin.qr) return; - activeLogin.qr = qr; + if (pendingQr) return; + pendingQr = qr; + const current = activeLogins.get(account.accountId); + if (current && !current.qr) current.qr = qr; clearTimeout(qrTimer); runtime.log(info("WhatsApp QR received.")); resolveQr?.(qr); @@ -150,12 +164,15 @@ export async function startWebLoginWithQr( }); } catch (err) { clearTimeout(qrTimer); - await resetActiveLogin(); + await resetActiveLogin(account.accountId); return { message: `Failed to start WhatsApp login: ${String(err)}`, }; } const login: ActiveLogin = { + accountId: account.accountId, + authDir: account.authDir, + isLegacyAuthDir: account.isLegacyAuthDir, id: randomUUID(), sock, startedAt: Date.now(), @@ -164,15 +181,16 @@ export async function startWebLoginWithQr( restartAttempted: false, verbose: Boolean(opts.verbose), }; - activeLogin = login; - attachLoginWaiter(login); + activeLogins.set(account.accountId, login); + if (pendingQr && !login.qr) login.qr = pendingQr; + attachLoginWaiter(account.accountId, login); let qr: string; try { qr = await qrPromise; } catch (err) { clearTimeout(qrTimer); - await resetActiveLogin(); + await resetActiveLogin(account.accountId); return { message: `Failed to get QR: ${String(err)}`, }; @@ -187,9 +205,12 @@ export async function startWebLoginWithQr( } export async function waitForWebLogin( - opts: { timeoutMs?: number; runtime?: RuntimeEnv } = {}, + opts: { timeoutMs?: number; runtime?: RuntimeEnv; accountId?: string } = {}, ): Promise<{ connected: boolean; message: string }> { const runtime = opts.runtime ?? defaultRuntime; + const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId }); + const activeLogin = activeLogins.get(account.accountId); if (!activeLogin) { return { connected: false, @@ -199,7 +220,7 @@ export async function waitForWebLogin( const login = activeLogin; if (!isLoginFresh(login)) { - await resetActiveLogin(); + await resetActiveLogin(account.accountId); return { connected: false, message: "The login QR expired. Ask me to generate a new one.", @@ -235,10 +256,14 @@ export async function waitForWebLogin( if (login.error) { if (login.errorStatus === DisconnectReason.loggedOut) { - await logoutWeb(runtime); + await logoutWeb({ + authDir: login.authDir, + isLegacyAuthDir: login.isLegacyAuthDir, + runtime, + }); const message = "WhatsApp reported the session is logged out. Cleared cached web session; please scan a new QR."; - await resetActiveLogin(message); + await resetActiveLogin(account.accountId, message); runtime.log(danger(message)); return { connected: false, message }; } @@ -249,7 +274,7 @@ export async function waitForWebLogin( } } const message = `WhatsApp login failed: ${login.error}`; - await resetActiveLogin(message); + await resetActiveLogin(account.accountId, message); runtime.log(danger(message)); return { connected: false, message }; } @@ -257,7 +282,7 @@ export async function waitForWebLogin( if (login.connected) { const message = "✅ Linked! WhatsApp is ready."; runtime.log(success(message)); - await resetActiveLogin(); + await resetActiveLogin(account.accountId); return { connected: true, message }; } diff --git a/src/web/login.coverage.test.ts b/src/web/login.coverage.test.ts index 2025eae9f..6933feff9 100644 --- a/src/web/login.coverage.test.ts +++ b/src/web/login.coverage.test.ts @@ -7,20 +7,36 @@ vi.useFakeTimers(); const rmMock = vi.spyOn(fs, "rm"); +vi.mock("../config/config.js", () => ({ + loadConfig: () => + ({ + whatsapp: { + accounts: { + default: { enabled: true, authDir: "/tmp/wa-creds" }, + }, + }, + }) as never, +})); + vi.mock("./session.js", () => { const sockA = { ws: { close: vi.fn() } }; const sockB = { ws: { close: vi.fn() } }; - const createWaSocket = vi.fn(async () => - createWaSocket.mock.calls.length === 0 ? sockA : sockB, - ); + let call = 0; + const createWaSocket = vi.fn(async () => (call++ === 0 ? sockA : sockB)); const waitForWaConnection = vi.fn(); const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`); return { createWaSocket, waitForWaConnection, formatError, - resolveWebAuthDir: () => "/tmp/wa-creds", WA_WEB_AUTH_DIR: "/tmp/wa-creds", + logoutWeb: vi.fn(async (params: { authDir?: string }) => { + await fs.rm(params.authDir ?? "/tmp/wa-creds", { + recursive: true, + force: true, + }); + return true; + }), }; }); diff --git a/src/web/login.ts b/src/web/login.ts index 9abbf4909..8ed15978f 100644 --- a/src/web/login.ts +++ b/src/web/login.ts @@ -1,30 +1,35 @@ -import fs from "node:fs/promises"; - import { DisconnectReason } from "@whiskeysockets/baileys"; - +import { loadConfig } from "../config/config.js"; import { danger, info, success } from "../globals.js"; import { logInfo } from "../logger.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { resolveWhatsAppAccount } from "./accounts.js"; import { createWaSocket, formatError, - resolveWebAuthDir, + logoutWeb, waitForWaConnection, } from "./session.js"; export async function loginWeb( verbose: boolean, provider = "whatsapp", - waitForConnection: typeof waitForWaConnection = waitForWaConnection, + waitForConnection?: typeof waitForWaConnection, runtime: RuntimeEnv = defaultRuntime, + accountId?: string, ) { if (provider !== "whatsapp" && provider !== "web") { throw new Error(`Unsupported provider: ${provider}`); } - const sock = await createWaSocket(true, verbose); + const wait = waitForConnection ?? waitForWaConnection; + const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ cfg, accountId }); + const sock = await createWaSocket(true, verbose, { + authDir: account.authDir, + }); logInfo("Waiting for WhatsApp connection...", runtime); try { - await waitForConnection(sock); + await wait(sock); console.log(success("✅ Linked! Credentials saved for future sends.")); } catch (err) { const code = @@ -42,9 +47,11 @@ export async function loginWeb( } catch { // ignore } - const retry = await createWaSocket(false, verbose); + const retry = await createWaSocket(false, verbose, { + authDir: account.authDir, + }); try { - await waitForConnection(retry); + await wait(retry); console.log( success( "✅ Linked after restart; web session ready. You can now send with provider=web.", @@ -56,7 +63,11 @@ export async function loginWeb( } } if (code === DisconnectReason.loggedOut) { - await fs.rm(resolveWebAuthDir(), { recursive: true, force: true }); + await logoutWeb({ + authDir: account.authDir, + isLegacyAuthDir: account.isLegacyAuthDir, + runtime, + }); console.error( danger( "WhatsApp reported the session is logged out. Cleared cached web session; please rerun clawdbot login and scan the QR again.", diff --git a/src/web/logout.test.ts b/src/web/logout.test.ts index af8dea03e..83f88e287 100644 --- a/src/web/logout.test.ts +++ b/src/web/logout.test.ts @@ -45,32 +45,41 @@ describe("web logout", () => { "deletes cached credentials when present", { timeout: 15_000 }, async () => { - const credsDir = path.join(tmpDir, ".clawdbot", "credentials"); - fs.mkdirSync(credsDir, { recursive: true }); - fs.writeFileSync(path.join(credsDir, "creds.json"), "{}"); - const sessionsPath = path.join( - tmpDir, - ".clawdbot", - "sessions", - "sessions.json", - ); - fs.mkdirSync(path.dirname(sessionsPath), { recursive: true }); - fs.writeFileSync(sessionsPath, "{}"); const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js"); expect(WA_WEB_AUTH_DIR.startsWith(tmpDir)).toBe(true); - const result = await logoutWeb(runtime as never); + fs.mkdirSync(WA_WEB_AUTH_DIR, { recursive: true }); + fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}"); + const result = await logoutWeb({ runtime: runtime as never }); expect(result).toBe(true); - expect(fs.existsSync(credsDir)).toBe(false); - expect(fs.existsSync(sessionsPath)).toBe(false); + expect(fs.existsSync(WA_WEB_AUTH_DIR)).toBe(false); }, ); it("no-ops when nothing to delete", { timeout: 15_000 }, async () => { const { logoutWeb } = await import("./session.js"); - const result = await logoutWeb(runtime as never); + const result = await logoutWeb({ runtime: runtime as never }); expect(result).toBe(false); expect(runtime.log).toHaveBeenCalled(); }); + + it("keeps shared oauth.json when using legacy auth dir", async () => { + const { logoutWeb } = await import("./session.js"); + const credsDir = path.join(tmpDir, ".clawdbot", "credentials"); + fs.mkdirSync(credsDir, { recursive: true }); + fs.writeFileSync(path.join(credsDir, "creds.json"), "{}"); + fs.writeFileSync(path.join(credsDir, "oauth.json"), '{"token":true}'); + fs.writeFileSync(path.join(credsDir, "session-abc.json"), "{}"); + + const result = await logoutWeb({ + authDir: credsDir, + isLegacyAuthDir: true, + runtime: runtime as never, + }); + expect(result).toBe(true); + expect(fs.existsSync(path.join(credsDir, "oauth.json"))).toBe(true); + expect(fs.existsSync(path.join(credsDir, "creds.json"))).toBe(false); + expect(fs.existsSync(path.join(credsDir, "session-abc.json"))).toBe(false); + }); }); diff --git a/src/web/outbound.ts b/src/web/outbound.ts index 5d4b3dfdc..120184c5f 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -16,12 +16,17 @@ const outboundLog = createSubsystemLogger("gateway/providers/whatsapp").child( export async function sendMessageWhatsApp( to: string, body: string, - options: { verbose: boolean; mediaUrl?: string; gifPlayback?: boolean }, + options: { + verbose: boolean; + mediaUrl?: string; + gifPlayback?: boolean; + accountId?: string; + }, ): Promise<{ messageId: string; toJid: string }> { let text = body; const correlationId = randomUUID(); const startedAt = Date.now(); - const active = getActiveWebListener(); + const active = getActiveWebListener(options.accountId); if (!active) { throw new Error( "No active gateway listener. Start the gateway before sending WhatsApp messages.", @@ -89,11 +94,11 @@ export async function sendMessageWhatsApp( export async function sendPollWhatsApp( to: string, poll: PollInput, - _options: { verbose: boolean }, + options: { verbose: boolean; accountId?: string }, ): Promise<{ messageId: string; toJid: string }> { const correlationId = randomUUID(); const startedAt = Date.now(); - const active = getActiveWebListener(); + const active = getActiveWebListener(options.accountId); if (!active) { throw new Error( "No active gateway listener. Start the gateway before sending WhatsApp polls.", diff --git a/src/web/session.test.ts b/src/web/session.test.ts index 1f2c29725..04efe1c56 100644 --- a/src/web/session.test.ts +++ b/src/web/session.test.ts @@ -70,22 +70,28 @@ describe("web session", () => { }); it("logWebSelfId prints cached E.164 when creds exist", () => { - const existsSpy = vi - .spyOn(fsSync, "existsSync") - .mockReturnValue(true as never); - const readSpy = vi - .spyOn(fsSync, "readFileSync") - .mockReturnValue(JSON.stringify({ me: { id: "12345@s.whatsapp.net" } })); + const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => { + if (typeof p !== "string") return false; + return p.endsWith("creds.json"); + }); + const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => { + if (typeof p === "string" && p.endsWith("creds.json")) { + return JSON.stringify({ me: { id: "12345@s.whatsapp.net" } }); + } + throw new Error(`unexpected readFileSync path: ${String(p)}`); + }); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn(), }; - logWebSelfId(runtime as never, true); + logWebSelfId("/tmp/wa-creds", runtime as never, true); expect(runtime.log).toHaveBeenCalledWith( - "Web Provider: +12345 (jid 12345@s.whatsapp.net)", + expect.stringContaining( + "Web Provider: +12345 (jid 12345@s.whatsapp.net)", + ), ); existsSpy.mockRestore(); readSpy.mockRestore(); @@ -111,7 +117,13 @@ describe("web session", () => { }); it("does not clobber creds backup when creds.json is corrupted", async () => { - const credsSuffix = path.join(".clawdbot", "credentials", "creds.json"); + const credsSuffix = path.join( + ".clawdbot", + "credentials", + "whatsapp", + "default", + "creds.json", + ); const copySpy = vi .spyOn(fsSync, "copyFileSync") @@ -191,10 +203,18 @@ describe("web session", () => { }); it("rotates creds backup when creds.json is valid JSON", async () => { - const credsSuffix = path.join(".clawdbot", "credentials", "creds.json"); + const credsSuffix = path.join( + ".clawdbot", + "credentials", + "whatsapp", + "default", + "creds.json", + ); const backupSuffix = path.join( ".clawdbot", "credentials", + "whatsapp", + "default", "creds.json.bak", ); diff --git a/src/web/session.ts b/src/web/session.ts index 6b69aa2cf..906f00875 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -10,41 +10,37 @@ import { useMultiFileAuthState, } from "@whiskeysockets/baileys"; import qrcode from "qrcode-terminal"; - -import { resolveDefaultSessionStorePath } from "../config/sessions.js"; +import { resolveOAuthDir } from "../config/paths.js"; import { danger, info, success } from "../globals.js"; import { getChildLogger, toPinoLikeLogger } from "../logging.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import type { Provider } from "../utils.js"; -import { - CONFIG_DIR, - ensureDir, - jidToE164, - resolveConfigDir, -} from "../utils.js"; +import { ensureDir, jidToE164, resolveUserPath } from "../utils.js"; import { VERSION } from "../version.js"; -export function resolveWebAuthDir() { - return path.join(resolveConfigDir(), "credentials"); +function resolveDefaultWebAuthDir(): string { + return path.join(resolveOAuthDir(), "whatsapp", DEFAULT_ACCOUNT_ID); } -function resolveWebCredsPath() { - return path.join(resolveWebAuthDir(), "creds.json"); +export const WA_WEB_AUTH_DIR = resolveDefaultWebAuthDir(); + +function resolveWebCredsPath(authDir: string) { + return path.join(authDir, "creds.json"); } -function resolveWebCredsBackupPath() { - return path.join(resolveWebAuthDir(), "creds.json.bak"); +function resolveWebCredsBackupPath(authDir: string) { + return path.join(authDir, "creds.json.bak"); } -export const WA_WEB_AUTH_DIR = path.join(CONFIG_DIR, "credentials"); - let credsSaveQueue: Promise = Promise.resolve(); function enqueueSaveCreds( + authDir: string, saveCreds: () => Promise | void, logger: ReturnType, ): void { credsSaveQueue = credsSaveQueue - .then(() => safeSaveCreds(saveCreds, logger)) + .then(() => safeSaveCreds(authDir, saveCreds, logger)) .catch((err) => { logger.warn({ error: String(err) }, "WhatsApp creds save queue error"); }); @@ -62,11 +58,12 @@ function readCredsJsonRaw(filePath: string): string | null { } function maybeRestoreCredsFromBackup( + authDir: string, logger: ReturnType, ): void { try { - const credsPath = resolveWebCredsPath(); - const backupPath = resolveWebCredsBackupPath(); + const credsPath = resolveWebCredsPath(authDir); + const backupPath = resolveWebCredsBackupPath(authDir); const raw = readCredsJsonRaw(credsPath); if (raw) { // Validate that creds.json is parseable. @@ -90,14 +87,15 @@ function maybeRestoreCredsFromBackup( } async function safeSaveCreds( + authDir: string, saveCreds: () => Promise | void, logger: ReturnType, ): Promise { try { // Best-effort backup so we can recover after abrupt restarts. // Important: don't clobber a good backup with a corrupted/truncated creds.json. - const credsPath = resolveWebCredsPath(); - const backupPath = resolveWebCredsBackupPath(); + const credsPath = resolveWebCredsPath(authDir); + const backupPath = resolveWebCredsBackupPath(authDir); const raw = readCredsJsonRaw(credsPath); if (raw) { try { @@ -124,7 +122,7 @@ async function safeSaveCreds( export async function createWaSocket( printQr: boolean, verbose: boolean, - opts: { onQr?: (qr: string) => void } = {}, + opts: { authDir?: string; onQr?: (qr: string) => void } = {}, ) { const baseLogger = getChildLogger( { module: "baileys" }, @@ -133,10 +131,10 @@ export async function createWaSocket( }, ); const logger = toPinoLikeLogger(baseLogger, verbose ? "info" : "silent"); - const authDir = resolveWebAuthDir(); + const authDir = resolveUserPath(opts.authDir ?? resolveDefaultWebAuthDir()); await ensureDir(authDir); const sessionLogger = getChildLogger({ module: "web-session" }); - maybeRestoreCredsFromBackup(sessionLogger); + maybeRestoreCredsFromBackup(authDir, sessionLogger); const { state, saveCreds } = await useMultiFileAuthState(authDir); const { version } = await fetchLatestBaileysVersion(); const sock = makeWASocket({ @@ -152,7 +150,9 @@ export async function createWaSocket( markOnlineOnConnect: false, }); - sock.ev.on("creds.update", () => enqueueSaveCreds(saveCreds, sessionLogger)); + sock.ev.on("creds.update", () => + enqueueSaveCreds(authDir, saveCreds, sessionLogger), + ); sock.ev.on( "connection.update", (update: Partial) => { @@ -330,13 +330,15 @@ export function formatError(err: unknown): string { return safeStringify(err); } -export async function webAuthExists() { +export async function webAuthExists( + authDir: string = resolveDefaultWebAuthDir(), +) { const sessionLogger = getChildLogger({ module: "web-session" }); - maybeRestoreCredsFromBackup(sessionLogger); - const authDir = resolveWebAuthDir(); - const credsPath = resolveWebCredsPath(); + const resolvedAuthDir = resolveUserPath(authDir); + maybeRestoreCredsFromBackup(resolvedAuthDir, sessionLogger); + const credsPath = resolveWebCredsPath(resolvedAuthDir); try { - await fs.access(authDir); + await fs.access(resolvedAuthDir); } catch { return false; } @@ -351,23 +353,50 @@ export async function webAuthExists() { } } -export async function logoutWeb(runtime: RuntimeEnv = defaultRuntime) { - const exists = await webAuthExists(); +async function clearLegacyBaileysAuthState(authDir: string) { + const entries = await fs.readdir(authDir, { withFileTypes: true }); + const shouldDelete = (name: string) => { + if (name === "oauth.json") return false; + if (name === "creds.json" || name === "creds.json.bak") return true; + if (!name.endsWith(".json")) return false; + return /^(app-state-sync|session|sender-key|pre-key)-/.test(name); + }; + await Promise.all( + entries.map(async (entry) => { + if (!entry.isFile()) return; + if (!shouldDelete(entry.name)) return; + await fs.rm(path.join(authDir, entry.name), { force: true }); + }), + ); +} + +export async function logoutWeb(params: { + authDir?: string; + isLegacyAuthDir?: boolean; + runtime?: RuntimeEnv; +}) { + const runtime = params.runtime ?? defaultRuntime; + const resolvedAuthDir = resolveUserPath( + params.authDir ?? resolveDefaultWebAuthDir(), + ); + const exists = await webAuthExists(resolvedAuthDir); if (!exists) { runtime.log(info("No WhatsApp Web session found; nothing to delete.")); return false; } - await fs.rm(resolveWebAuthDir(), { recursive: true, force: true }); - // Also drop session store to clear lingering per-sender state after logout. - await fs.rm(resolveDefaultSessionStorePath(), { force: true }); + if (params.isLegacyAuthDir) { + await clearLegacyBaileysAuthState(resolvedAuthDir); + } else { + await fs.rm(resolvedAuthDir, { recursive: true, force: true }); + } runtime.log(success("Cleared WhatsApp Web credentials.")); return true; } -export function readWebSelfId() { +export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) { // Read the cached WhatsApp Web identity (jid + E.164) from disk if present. try { - const credsPath = resolveWebCredsPath(); + const credsPath = resolveWebCredsPath(resolveUserPath(authDir)); if (!fsSync.existsSync(credsPath)) { return { e164: null, jid: null } as const; } @@ -385,9 +414,13 @@ export function readWebSelfId() { * Return the age (in milliseconds) of the cached WhatsApp web auth state, or null when missing. * Helpful for heartbeats/observability to spot stale credentials. */ -export function getWebAuthAgeMs(): number | null { +export function getWebAuthAgeMs( + authDir: string = resolveDefaultWebAuthDir(), +): number | null { try { - const stats = fsSync.statSync(resolveWebCredsPath()); + const stats = fsSync.statSync( + resolveWebCredsPath(resolveUserPath(authDir)), + ); return Date.now() - stats.mtimeMs; } catch { return null; @@ -399,11 +432,12 @@ export function newConnectionId() { } export function logWebSelfId( + authDir: string = resolveDefaultWebAuthDir(), runtime: RuntimeEnv = defaultRuntime, includeProviderPrefix = false, ) { // Human-friendly log of the currently linked personal web session. - const { e164, jid } = readWebSelfId(); + const { e164, jid } = readWebSelfId(authDir); const details = e164 || jid ? `${e164 ?? "unknown"}${jid ? ` (jid ${jid})` : ""}` @@ -412,9 +446,12 @@ export function logWebSelfId( runtime.log(info(`${prefix}${details}`)); } -export async function pickProvider(pref: Provider | "auto"): Promise { +export async function pickProvider( + pref: Provider | "auto", + authDir: string = resolveDefaultWebAuthDir(), +): Promise { const choice: Provider = pref === "auto" ? "web" : pref; - const hasWeb = await webAuthExists(); + const hasWeb = await webAuthExists(authDir); if (!hasWeb) { throw new Error( "No WhatsApp Web session found. Run `clawdbot login --verbose` to link.", diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 1b46c70f8..660fe5ec4 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -161,7 +161,7 @@ const DEFAULT_CRON_FORM: CronFormState = { payloadKind: "systemEvent", payloadText: "", deliver: false, - channel: "last", + provider: "last", to: "", timeoutSeconds: "", postToMainPrefix: "", diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index f0d24d472..342a1c8f0 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -73,7 +73,7 @@ export function buildCronPayload(form: CronFormState) { kind: "agentTurn"; message: string; deliver?: boolean; - channel?: + provider?: | "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.channel) payload.channel = form.channel; + if (form.provider) payload.provider = form.provider; if (form.to.trim()) payload.to = form.to.trim(); const timeoutSeconds = toNumber(form.timeoutSeconds, 0); if (timeoutSeconds > 0) payload.timeoutSeconds = timeoutSeconds; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index bd3a002f1..7611a93d3 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -271,7 +271,7 @@ export type CronPayload = thinking?: string; timeoutSeconds?: number; deliver?: boolean; - channel?: + provider?: | "last" | "whatsapp" | "telegram" diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 90a1372d8..e78c584cf 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -162,7 +162,7 @@ export type CronFormState = { payloadKind: "systemEvent" | "agentTurn"; payloadText: string; deliver: boolean; - channel: + provider: | "last" | "whatsapp" | "telegram" diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index cbb2efc25..53d7bf199 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -174,12 +174,12 @@ export function renderCron(props: CronProps) { />