feat: multi-agent routing + multi-account providers
This commit is contained in:
parent
50d4b17417
commit
dbfa316d19
@ -187,7 +187,7 @@ actor BridgeServer {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
deliver: false,
|
deliver: false,
|
||||||
to: nil,
|
to: nil,
|
||||||
channel: .last))
|
provider: .last))
|
||||||
|
|
||||||
case "agent.request":
|
case "agent.request":
|
||||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||||
@ -205,7 +205,7 @@ actor BridgeServer {
|
|||||||
?? "node-\(nodeId)"
|
?? "node-\(nodeId)"
|
||||||
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
let channel = GatewayAgentChannel(raw: link.channel)
|
let provider = GatewayAgentProvider(raw: link.channel)
|
||||||
|
|
||||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||||
message: message,
|
message: message,
|
||||||
@ -213,7 +213,7 @@ actor BridgeServer {
|
|||||||
thinking: thinking,
|
thinking: thinking,
|
||||||
deliver: link.deliver,
|
deliver: link.deliver,
|
||||||
to: to,
|
to: to,
|
||||||
channel: channel))
|
provider: provider))
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
|||||||
@ -79,14 +79,14 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
|||||||
GatewayProcessManager.shared.setActive(true)
|
GatewayProcessManager.shared.setActive(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||||
message: text,
|
message: text,
|
||||||
sessionKey: self.sessionKey,
|
sessionKey: self.sessionKey,
|
||||||
thinking: "low",
|
thinking: "low",
|
||||||
deliver: false,
|
deliver: false,
|
||||||
to: nil,
|
to: nil,
|
||||||
channel: .last,
|
provider: .last,
|
||||||
idempotencyKey: actionId))
|
idempotencyKey: actionId))
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
guard let webView else { return }
|
guard let webView else { return }
|
||||||
|
|||||||
@ -33,13 +33,13 @@ extension CronJobEditor {
|
|||||||
case let .systemEvent(text):
|
case let .systemEvent(text):
|
||||||
self.payloadKind = .systemEvent
|
self.payloadKind = .systemEvent
|
||||||
self.systemEventText = text
|
self.systemEventText = text
|
||||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
|
||||||
self.payloadKind = .agentTurn
|
self.payloadKind = .agentTurn
|
||||||
self.agentMessage = message
|
self.agentMessage = message
|
||||||
self.thinking = thinking ?? ""
|
self.thinking = thinking ?? ""
|
||||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||||
self.deliver = deliver ?? false
|
self.deliver = deliver ?? false
|
||||||
self.channel = GatewayAgentChannel(raw: channel)
|
self.provider = GatewayAgentProvider(raw: provider)
|
||||||
self.to = to ?? ""
|
self.to = to ?? ""
|
||||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||||
}
|
}
|
||||||
@ -166,7 +166,7 @@ extension CronJobEditor {
|
|||||||
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||||
payload["deliver"] = self.deliver
|
payload["deliver"] = self.deliver
|
||||||
if self.deliver {
|
if self.deliver {
|
||||||
payload["channel"] = self.channel.rawValue
|
payload["provider"] = self.provider.rawValue
|
||||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !to.isEmpty { payload["to"] = to }
|
if !to.isEmpty { payload["to"] = to }
|
||||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||||
|
|||||||
@ -13,7 +13,7 @@ extension CronJobEditor {
|
|||||||
self.payloadKind = .agentTurn
|
self.payloadKind = .agentTurn
|
||||||
self.agentMessage = "Run diagnostic"
|
self.agentMessage = "Run diagnostic"
|
||||||
self.deliver = true
|
self.deliver = true
|
||||||
self.channel = .last
|
self.provider = .last
|
||||||
self.to = "+15551230000"
|
self.to = "+15551230000"
|
||||||
self.thinking = "low"
|
self.thinking = "low"
|
||||||
self.timeoutSeconds = "90"
|
self.timeoutSeconds = "90"
|
||||||
|
|||||||
@ -17,7 +17,7 @@ struct CronJobEditor: View {
|
|||||||
static let scheduleKindNote =
|
static let scheduleKindNote =
|
||||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||||
static let isolatedPayloadNote =
|
static let isolatedPayloadNote =
|
||||||
"Isolated jobs always run an agent turn. The result can be delivered to a 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."
|
+ "and a short summary is posted back to your main chat."
|
||||||
static let mainPayloadNote =
|
static let mainPayloadNote =
|
||||||
"System events are injected into the current main session. Agent turns require an isolated session target."
|
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||||||
@ -42,7 +42,7 @@ struct CronJobEditor: View {
|
|||||||
@State var systemEventText: String = ""
|
@State var systemEventText: String = ""
|
||||||
@State var agentMessage: String = ""
|
@State var agentMessage: String = ""
|
||||||
@State var deliver: Bool = false
|
@State var deliver: Bool = false
|
||||||
@State var channel: GatewayAgentChannel = .last
|
@State var provider: GatewayAgentProvider = .last
|
||||||
@State var to: String = ""
|
@State var to: String = ""
|
||||||
@State var thinking: String = ""
|
@State var thinking: String = ""
|
||||||
@State var timeoutSeconds: String = ""
|
@State var timeoutSeconds: String = ""
|
||||||
@ -309,7 +309,7 @@ struct CronJobEditor: View {
|
|||||||
}
|
}
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Deliver")
|
self.gridLabel("Deliver")
|
||||||
Toggle("Deliver result to a surface", isOn: self.$deliver)
|
Toggle("Deliver result to a provider", isOn: self.$deliver)
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -317,15 +317,15 @@ struct CronJobEditor: View {
|
|||||||
if self.deliver {
|
if self.deliver {
|
||||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Channel")
|
self.gridLabel("Provider")
|
||||||
Picker("", selection: self.$channel) {
|
Picker("", selection: self.$provider) {
|
||||||
Text("last").tag(GatewayAgentChannel.last)
|
Text("last").tag(GatewayAgentProvider.last)
|
||||||
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
Text("whatsapp").tag(GatewayAgentProvider.whatsapp)
|
||||||
Text("telegram").tag(GatewayAgentChannel.telegram)
|
Text("telegram").tag(GatewayAgentProvider.telegram)
|
||||||
Text("discord").tag(GatewayAgentChannel.discord)
|
Text("discord").tag(GatewayAgentProvider.discord)
|
||||||
Text("slack").tag(GatewayAgentChannel.slack)
|
Text("slack").tag(GatewayAgentProvider.slack)
|
||||||
Text("signal").tag(GatewayAgentChannel.signal)
|
Text("signal").tag(GatewayAgentProvider.signal)
|
||||||
Text("imessage").tag(GatewayAgentChannel.imessage)
|
Text("imessage").tag(GatewayAgentProvider.imessage)
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
|||||||
@ -74,12 +74,12 @@ enum CronPayload: Codable, Equatable {
|
|||||||
thinking: String?,
|
thinking: String?,
|
||||||
timeoutSeconds: Int?,
|
timeoutSeconds: Int?,
|
||||||
deliver: Bool?,
|
deliver: Bool?,
|
||||||
channel: String?,
|
provider: String?,
|
||||||
to: String?,
|
to: String?,
|
||||||
bestEffortDeliver: Bool?)
|
bestEffortDeliver: Bool?)
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
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 {
|
var kind: String {
|
||||||
@ -101,7 +101,7 @@ enum CronPayload: Codable, Equatable {
|
|||||||
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
|
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
|
||||||
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
|
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
|
||||||
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
|
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
|
||||||
channel: container.decodeIfPresent(String.self, forKey: .channel),
|
provider: container.decodeIfPresent(String.self, forKey: .provider),
|
||||||
to: container.decodeIfPresent(String.self, forKey: .to),
|
to: container.decodeIfPresent(String.self, forKey: .to),
|
||||||
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
|
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
|
||||||
default:
|
default:
|
||||||
@ -118,12 +118,12 @@ enum CronPayload: Codable, Equatable {
|
|||||||
switch self {
|
switch self {
|
||||||
case let .systemEvent(text):
|
case let .systemEvent(text):
|
||||||
try container.encode(text, forKey: .text)
|
try container.encode(text, forKey: .text)
|
||||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
|
||||||
try container.encode(message, forKey: .message)
|
try container.encode(message, forKey: .message)
|
||||||
try container.encodeIfPresent(thinking, forKey: .thinking)
|
try container.encodeIfPresent(thinking, forKey: .thinking)
|
||||||
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
||||||
try container.encodeIfPresent(deliver, forKey: .deliver)
|
try container.encodeIfPresent(deliver, forKey: .deliver)
|
||||||
try container.encodeIfPresent(channel, forKey: .channel)
|
try container.encodeIfPresent(provider, forKey: .provider)
|
||||||
try container.encodeIfPresent(to, forKey: .to)
|
try container.encodeIfPresent(to, forKey: .to)
|
||||||
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
|
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -206,7 +206,7 @@ extension CronSettings {
|
|||||||
Text(text)
|
Text(text)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.textSelection(.enabled)
|
.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) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
@ -216,7 +216,7 @@ extension CronSettings {
|
|||||||
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
|
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
|
||||||
if deliver ?? false {
|
if deliver ?? false {
|
||||||
StatusPill(text: "deliver", tint: .secondary)
|
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) }
|
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ struct CronSettings_Previews: PreviewProvider {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
timeoutSeconds: 600,
|
timeoutSeconds: 600,
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "last",
|
provider: "last",
|
||||||
to: nil,
|
to: nil,
|
||||||
bestEffortDeliver: true),
|
bestEffortDeliver: true),
|
||||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||||
@ -72,7 +72,7 @@ extension CronSettings {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
timeoutSeconds: 120,
|
timeoutSeconds: 120,
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "whatsapp",
|
provider: "whatsapp",
|
||||||
to: "+15551234567",
|
to: "+15551234567",
|
||||||
bestEffortDeliver: true),
|
bestEffortDeliver: true),
|
||||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||||
|
|||||||
@ -59,7 +59,7 @@ final class DeepLinkHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let channel = GatewayAgentChannel(raw: link.channel)
|
let provider = GatewayAgentProvider(raw: link.channel)
|
||||||
let explicitSessionKey = link.sessionKey?
|
let explicitSessionKey = link.sessionKey?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.nonEmpty
|
.nonEmpty
|
||||||
@ -72,9 +72,9 @@ final class DeepLinkHandler {
|
|||||||
message: messagePreview,
|
message: messagePreview,
|
||||||
sessionKey: resolvedSessionKey,
|
sessionKey: resolvedSessionKey,
|
||||||
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||||
deliver: channel.shouldDeliver(link.deliver),
|
deliver: provider.shouldDeliver(link.deliver),
|
||||||
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||||
channel: channel,
|
provider: provider,
|
||||||
timeoutSeconds: link.timeoutSeconds,
|
timeoutSeconds: link.timeoutSeconds,
|
||||||
idempotencyKey: UUID().uuidString)
|
idempotencyKey: UUID().uuidString)
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import OSLog
|
|||||||
|
|
||||||
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection")
|
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection")
|
||||||
|
|
||||||
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
|
||||||
case last
|
case last
|
||||||
case whatsapp
|
case whatsapp
|
||||||
case telegram
|
case telegram
|
||||||
@ -17,7 +17,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
|||||||
|
|
||||||
init(raw: String?) {
|
init(raw: String?) {
|
||||||
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
self = GatewayAgentChannel(rawValue: normalized) ?? .last
|
self = GatewayAgentProvider(rawValue: normalized) ?? .last
|
||||||
}
|
}
|
||||||
|
|
||||||
var isDeliverable: Bool { self != .webchat }
|
var isDeliverable: Bool { self != .webchat }
|
||||||
@ -31,7 +31,7 @@ struct GatewayAgentInvocation: Sendable {
|
|||||||
var thinking: String?
|
var thinking: String?
|
||||||
var deliver: Bool = false
|
var deliver: Bool = false
|
||||||
var to: String?
|
var to: String?
|
||||||
var channel: GatewayAgentChannel = .last
|
var provider: GatewayAgentProvider = .last
|
||||||
var timeoutSeconds: Int?
|
var timeoutSeconds: Int?
|
||||||
var idempotencyKey: String = UUID().uuidString
|
var idempotencyKey: String = UUID().uuidString
|
||||||
}
|
}
|
||||||
@ -368,7 +368,7 @@ extension GatewayConnection {
|
|||||||
"thinking": AnyCodable(invocation.thinking ?? "default"),
|
"thinking": AnyCodable(invocation.thinking ?? "default"),
|
||||||
"deliver": AnyCodable(invocation.deliver),
|
"deliver": AnyCodable(invocation.deliver),
|
||||||
"to": AnyCodable(invocation.to ?? ""),
|
"to": AnyCodable(invocation.to ?? ""),
|
||||||
"channel": AnyCodable(invocation.channel.rawValue),
|
"provider": AnyCodable(invocation.provider.rawValue),
|
||||||
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
|
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
|
||||||
]
|
]
|
||||||
if let timeout = invocation.timeoutSeconds {
|
if let timeout = invocation.timeoutSeconds {
|
||||||
@ -389,7 +389,7 @@ extension GatewayConnection {
|
|||||||
sessionKey: String,
|
sessionKey: String,
|
||||||
deliver: Bool,
|
deliver: Bool,
|
||||||
to: String?,
|
to: String?,
|
||||||
channel: GatewayAgentChannel = .last,
|
provider: GatewayAgentProvider = .last,
|
||||||
timeoutSeconds: Int? = nil,
|
timeoutSeconds: Int? = nil,
|
||||||
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
|
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
|
||||||
{
|
{
|
||||||
@ -399,7 +399,7 @@ extension GatewayConnection {
|
|||||||
thinking: thinking,
|
thinking: thinking,
|
||||||
deliver: deliver,
|
deliver: deliver,
|
||||||
to: to,
|
to: to,
|
||||||
channel: channel,
|
provider: provider,
|
||||||
timeoutSeconds: timeoutSeconds,
|
timeoutSeconds: timeoutSeconds,
|
||||||
idempotencyKey: idempotencyKey))
|
idempotencyKey: idempotencyKey))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ struct GatewaySessionDefaultsRecord: Codable {
|
|||||||
struct GatewaySessionEntryRecord: Codable {
|
struct GatewaySessionEntryRecord: Codable {
|
||||||
let key: String
|
let key: String
|
||||||
let displayName: String?
|
let displayName: String?
|
||||||
let surface: String?
|
let provider: String?
|
||||||
let subject: String?
|
let subject: String?
|
||||||
let room: String?
|
let room: String?
|
||||||
let space: String?
|
let space: String?
|
||||||
@ -71,7 +71,7 @@ struct SessionRow: Identifiable {
|
|||||||
let key: String
|
let key: String
|
||||||
let kind: SessionKind
|
let kind: SessionKind
|
||||||
let displayName: String?
|
let displayName: String?
|
||||||
let surface: String?
|
let provider: String?
|
||||||
let subject: String?
|
let subject: String?
|
||||||
let room: String?
|
let room: String?
|
||||||
let space: String?
|
let space: String?
|
||||||
@ -141,7 +141,7 @@ extension SessionRow {
|
|||||||
key: "user@example.com",
|
key: "user@example.com",
|
||||||
kind: .direct,
|
kind: .direct,
|
||||||
displayName: nil,
|
displayName: nil,
|
||||||
surface: nil,
|
provider: nil,
|
||||||
subject: nil,
|
subject: nil,
|
||||||
room: nil,
|
room: nil,
|
||||||
space: nil,
|
space: nil,
|
||||||
@ -158,7 +158,7 @@ extension SessionRow {
|
|||||||
key: "discord:channel:release-squad",
|
key: "discord:channel:release-squad",
|
||||||
kind: .group,
|
kind: .group,
|
||||||
displayName: "discord:#release-squad",
|
displayName: "discord:#release-squad",
|
||||||
surface: "discord",
|
provider: "discord",
|
||||||
subject: nil,
|
subject: nil,
|
||||||
room: "#release-squad",
|
room: "#release-squad",
|
||||||
space: nil,
|
space: nil,
|
||||||
@ -175,7 +175,7 @@ extension SessionRow {
|
|||||||
key: "global",
|
key: "global",
|
||||||
kind: .global,
|
kind: .global,
|
||||||
displayName: nil,
|
displayName: nil,
|
||||||
surface: nil,
|
provider: nil,
|
||||||
subject: nil,
|
subject: nil,
|
||||||
room: nil,
|
room: nil,
|
||||||
space: nil,
|
space: nil,
|
||||||
@ -298,7 +298,7 @@ enum SessionLoader {
|
|||||||
key: entry.key,
|
key: entry.key,
|
||||||
kind: SessionKind.from(key: entry.key),
|
kind: SessionKind.from(key: entry.key),
|
||||||
displayName: entry.displayName,
|
displayName: entry.displayName,
|
||||||
surface: entry.surface,
|
provider: entry.provider,
|
||||||
subject: entry.subject,
|
subject: entry.subject,
|
||||||
room: entry.room,
|
room: entry.room,
|
||||||
space: entry.space,
|
space: entry.space,
|
||||||
|
|||||||
@ -37,7 +37,7 @@ enum VoiceWakeForwarder {
|
|||||||
var thinking: String = "low"
|
var thinking: String = "low"
|
||||||
var deliver: Bool = true
|
var deliver: Bool = true
|
||||||
var to: String?
|
var to: String?
|
||||||
var channel: GatewayAgentChannel = .last
|
var provider: GatewayAgentProvider = .last
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@ -46,14 +46,14 @@ enum VoiceWakeForwarder {
|
|||||||
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
|
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
|
||||||
{
|
{
|
||||||
let payload = Self.prefixedTranscript(transcript)
|
let payload = Self.prefixedTranscript(transcript)
|
||||||
let deliver = options.channel.shouldDeliver(options.deliver)
|
let deliver = options.provider.shouldDeliver(options.deliver)
|
||||||
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||||
message: payload,
|
message: payload,
|
||||||
sessionKey: options.sessionKey,
|
sessionKey: options.sessionKey,
|
||||||
thinking: options.thinking,
|
thinking: options.thinking,
|
||||||
deliver: deliver,
|
deliver: deliver,
|
||||||
to: options.to,
|
to: options.to,
|
||||||
channel: options.channel))
|
provider: options.provider))
|
||||||
|
|
||||||
if result.ok {
|
if result.ok {
|
||||||
self.logger.info("voice wake forward ok")
|
self.logger.info("voice wake forward ok")
|
||||||
|
|||||||
55
src/agents/agent-scope.ts
Normal file
55
src/agents/agent-scope.ts
Normal file
@ -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");
|
||||||
|
}
|
||||||
@ -49,14 +49,14 @@ export type AuthProfileStore = {
|
|||||||
|
|
||||||
type LegacyAuthStore = Record<string, AuthProfileCredential>;
|
type LegacyAuthStore = Record<string, AuthProfileCredential>;
|
||||||
|
|
||||||
function resolveAuthStorePath(): string {
|
function resolveAuthStorePath(agentDir?: string): string {
|
||||||
const agentDir = resolveClawdbotAgentDir();
|
const resolved = resolveUserPath(agentDir ?? resolveClawdbotAgentDir());
|
||||||
return path.join(agentDir, AUTH_PROFILE_FILENAME);
|
return path.join(resolved, AUTH_PROFILE_FILENAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLegacyAuthStorePath(): string {
|
function resolveLegacyAuthStorePath(agentDir?: string): string {
|
||||||
const agentDir = resolveClawdbotAgentDir();
|
const resolved = resolveUserPath(agentDir ?? resolveClawdbotAgentDir());
|
||||||
return path.join(agentDir, LEGACY_AUTH_FILENAME);
|
return path.join(resolved, LEGACY_AUTH_FILENAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadJsonFile(pathname: string): unknown {
|
function loadJsonFile(pathname: string): unknown {
|
||||||
@ -104,8 +104,9 @@ function buildOAuthApiKey(
|
|||||||
async function refreshOAuthTokenWithLock(params: {
|
async function refreshOAuthTokenWithLock(params: {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
provider: OAuthProvider;
|
provider: OAuthProvider;
|
||||||
|
agentDir?: string;
|
||||||
}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
|
}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
|
||||||
const authPath = resolveAuthStorePath();
|
const authPath = resolveAuthStorePath(params.agentDir);
|
||||||
ensureAuthStoreFile(authPath);
|
ensureAuthStoreFile(authPath);
|
||||||
|
|
||||||
let release: (() => Promise<void>) | undefined;
|
let release: (() => Promise<void>) | undefined;
|
||||||
@ -121,7 +122,7 @@ async function refreshOAuthTokenWithLock(params: {
|
|||||||
stale: 30_000,
|
stale: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const store = ensureAuthProfileStore();
|
const store = ensureAuthProfileStore(params.agentDir);
|
||||||
const cred = store.profiles[params.profileId];
|
const cred = store.profiles[params.profileId];
|
||||||
if (!cred || cred.type !== "oauth") return null;
|
if (!cred || cred.type !== "oauth") return null;
|
||||||
|
|
||||||
@ -142,7 +143,7 @@ async function refreshOAuthTokenWithLock(params: {
|
|||||||
...result.newCredentials,
|
...result.newCredentials,
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
};
|
};
|
||||||
saveAuthProfileStore(store);
|
saveAuthProfileStore(store, params.agentDir);
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
if (release) {
|
if (release) {
|
||||||
@ -261,13 +262,13 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
|||||||
return { version: AUTH_STORE_VERSION, profiles: {} };
|
return { version: AUTH_STORE_VERSION, profiles: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureAuthProfileStore(): AuthProfileStore {
|
export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
|
||||||
const authPath = resolveAuthStorePath();
|
const authPath = resolveAuthStorePath(agentDir);
|
||||||
const raw = loadJsonFile(authPath);
|
const raw = loadJsonFile(authPath);
|
||||||
const asStore = coerceAuthStore(raw);
|
const asStore = coerceAuthStore(raw);
|
||||||
if (asStore) return asStore;
|
if (asStore) return asStore;
|
||||||
|
|
||||||
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
|
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
|
||||||
const legacy = coerceLegacyStore(legacyRaw);
|
const legacy = coerceLegacyStore(legacyRaw);
|
||||||
const store: AuthProfileStore = {
|
const store: AuthProfileStore = {
|
||||||
version: AUTH_STORE_VERSION,
|
version: AUTH_STORE_VERSION,
|
||||||
@ -307,8 +308,11 @@ export function ensureAuthProfileStore(): AuthProfileStore {
|
|||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveAuthProfileStore(store: AuthProfileStore): void {
|
export function saveAuthProfileStore(
|
||||||
const authPath = resolveAuthStorePath();
|
store: AuthProfileStore,
|
||||||
|
agentDir?: string,
|
||||||
|
): void {
|
||||||
|
const authPath = resolveAuthStorePath(agentDir);
|
||||||
const payload = {
|
const payload = {
|
||||||
version: AUTH_STORE_VERSION,
|
version: AUTH_STORE_VERSION,
|
||||||
profiles: store.profiles,
|
profiles: store.profiles,
|
||||||
@ -321,10 +325,11 @@ export function saveAuthProfileStore(store: AuthProfileStore): void {
|
|||||||
export function upsertAuthProfile(params: {
|
export function upsertAuthProfile(params: {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
credential: AuthProfileCredential;
|
credential: AuthProfileCredential;
|
||||||
|
agentDir?: string;
|
||||||
}): void {
|
}): void {
|
||||||
const store = ensureAuthProfileStore();
|
const store = ensureAuthProfileStore(params.agentDir);
|
||||||
store.profiles[params.profileId] = params.credential;
|
store.profiles[params.profileId] = params.credential;
|
||||||
saveAuthProfileStore(store);
|
saveAuthProfileStore(store, params.agentDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listProfilesForProvider(
|
export function listProfilesForProvider(
|
||||||
@ -354,8 +359,9 @@ export function isProfileInCooldown(
|
|||||||
export function markAuthProfileUsed(params: {
|
export function markAuthProfileUsed(params: {
|
||||||
store: AuthProfileStore;
|
store: AuthProfileStore;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
|
agentDir?: string;
|
||||||
}): void {
|
}): void {
|
||||||
const { store, profileId } = params;
|
const { store, profileId, agentDir } = params;
|
||||||
if (!store.profiles[profileId]) return;
|
if (!store.profiles[profileId]) return;
|
||||||
|
|
||||||
store.usageStats = store.usageStats ?? {};
|
store.usageStats = store.usageStats ?? {};
|
||||||
@ -365,7 +371,7 @@ export function markAuthProfileUsed(params: {
|
|||||||
errorCount: 0,
|
errorCount: 0,
|
||||||
cooldownUntil: undefined,
|
cooldownUntil: undefined,
|
||||||
};
|
};
|
||||||
saveAuthProfileStore(store);
|
saveAuthProfileStore(store, agentDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateAuthProfileCooldownMs(errorCount: number): number {
|
export function calculateAuthProfileCooldownMs(errorCount: number): number {
|
||||||
@ -383,8 +389,9 @@ export function calculateAuthProfileCooldownMs(errorCount: number): number {
|
|||||||
export function markAuthProfileCooldown(params: {
|
export function markAuthProfileCooldown(params: {
|
||||||
store: AuthProfileStore;
|
store: AuthProfileStore;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
|
agentDir?: string;
|
||||||
}): void {
|
}): void {
|
||||||
const { store, profileId } = params;
|
const { store, profileId, agentDir } = params;
|
||||||
if (!store.profiles[profileId]) return;
|
if (!store.profiles[profileId]) return;
|
||||||
|
|
||||||
store.usageStats = store.usageStats ?? {};
|
store.usageStats = store.usageStats ?? {};
|
||||||
@ -399,7 +406,7 @@ export function markAuthProfileCooldown(params: {
|
|||||||
errorCount,
|
errorCount,
|
||||||
cooldownUntil: Date.now() + backoffMs,
|
cooldownUntil: Date.now() + backoffMs,
|
||||||
};
|
};
|
||||||
saveAuthProfileStore(store);
|
saveAuthProfileStore(store, agentDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -408,8 +415,9 @@ export function markAuthProfileCooldown(params: {
|
|||||||
export function clearAuthProfileCooldown(params: {
|
export function clearAuthProfileCooldown(params: {
|
||||||
store: AuthProfileStore;
|
store: AuthProfileStore;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
|
agentDir?: string;
|
||||||
}): void {
|
}): void {
|
||||||
const { store, profileId } = params;
|
const { store, profileId, agentDir } = params;
|
||||||
if (!store.usageStats?.[profileId]) return;
|
if (!store.usageStats?.[profileId]) return;
|
||||||
|
|
||||||
store.usageStats[profileId] = {
|
store.usageStats[profileId] = {
|
||||||
@ -417,7 +425,7 @@ export function clearAuthProfileCooldown(params: {
|
|||||||
errorCount: 0,
|
errorCount: 0,
|
||||||
cooldownUntil: undefined,
|
cooldownUntil: undefined,
|
||||||
};
|
};
|
||||||
saveAuthProfileStore(store);
|
saveAuthProfileStore(store, agentDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAuthProfileOrder(params: {
|
export function resolveAuthProfileOrder(params: {
|
||||||
@ -527,6 +535,7 @@ export async function resolveApiKeyForProfile(params: {
|
|||||||
cfg?: ClawdbotConfig;
|
cfg?: ClawdbotConfig;
|
||||||
store: AuthProfileStore;
|
store: AuthProfileStore;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
|
agentDir?: string;
|
||||||
}): Promise<{ apiKey: string; provider: string; email?: string } | null> {
|
}): Promise<{ apiKey: string; provider: string; email?: string } | null> {
|
||||||
const { cfg, store, profileId } = params;
|
const { cfg, store, profileId } = params;
|
||||||
const cred = store.profiles[profileId];
|
const cred = store.profiles[profileId];
|
||||||
@ -550,6 +559,7 @@ export async function resolveApiKeyForProfile(params: {
|
|||||||
const result = await refreshOAuthTokenWithLock({
|
const result = await refreshOAuthTokenWithLock({
|
||||||
profileId,
|
profileId,
|
||||||
provider: cred.provider,
|
provider: cred.provider,
|
||||||
|
agentDir: params.agentDir,
|
||||||
});
|
});
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
return {
|
return {
|
||||||
@ -558,7 +568,7 @@ export async function resolveApiKeyForProfile(params: {
|
|||||||
email: cred.email,
|
email: cred.email,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const refreshedStore = ensureAuthProfileStore();
|
const refreshedStore = ensureAuthProfileStore(params.agentDir);
|
||||||
const refreshed = refreshedStore.profiles[profileId];
|
const refreshed = refreshedStore.profiles[profileId];
|
||||||
if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) {
|
if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) {
|
||||||
return {
|
return {
|
||||||
@ -579,12 +589,13 @@ export function markAuthProfileGood(params: {
|
|||||||
store: AuthProfileStore;
|
store: AuthProfileStore;
|
||||||
provider: string;
|
provider: string;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
|
agentDir?: string;
|
||||||
}): void {
|
}): void {
|
||||||
const { store, provider, profileId } = params;
|
const { store, provider, profileId, agentDir } = params;
|
||||||
const profile = store.profiles[profileId];
|
const profile = store.profiles[profileId];
|
||||||
if (!profile || profile.provider !== provider) return;
|
if (!profile || profile.provider !== provider) return;
|
||||||
store.lastGood = { ...store.lastGood, [provider]: profileId };
|
store.lastGood = { ...store.lastGood, [provider]: profileId };
|
||||||
saveAuthProfileStore(store);
|
saveAuthProfileStore(store, agentDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAuthStorePathForDisplay(): string {
|
export function resolveAuthStorePathForDisplay(): string {
|
||||||
|
|||||||
@ -36,14 +36,14 @@ describe("sessions tools", () => {
|
|||||||
kind: "direct",
|
kind: "direct",
|
||||||
sessionId: "s-main",
|
sessionId: "s-main",
|
||||||
updatedAt: 10,
|
updatedAt: 10,
|
||||||
lastChannel: "whatsapp",
|
lastProvider: "whatsapp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "discord:group:dev",
|
key: "discord:group:dev",
|
||||||
kind: "group",
|
kind: "group",
|
||||||
sessionId: "s-group",
|
sessionId: "s-group",
|
||||||
updatedAt: 11,
|
updatedAt: 11,
|
||||||
surface: "discord",
|
provider: "discord",
|
||||||
displayName: "discord:g-dev",
|
displayName: "discord:g-dev",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -196,7 +196,7 @@ describe("sessions tools", () => {
|
|||||||
|
|
||||||
const tool = createClawdbotTools({
|
const tool = createClawdbotTools({
|
||||||
agentSessionKey: requesterKey,
|
agentSessionKey: requesterKey,
|
||||||
agentSurface: "discord",
|
agentProvider: "discord",
|
||||||
}).find((candidate) => candidate.name === "sessions_send");
|
}).find((candidate) => candidate.name === "sessions_send");
|
||||||
expect(tool).toBeDefined();
|
expect(tool).toBeDefined();
|
||||||
if (!tool) throw new Error("missing sessions_send tool");
|
if (!tool) throw new Error("missing sessions_send tool");
|
||||||
@ -340,7 +340,7 @@ describe("sessions tools", () => {
|
|||||||
|
|
||||||
const tool = createClawdbotTools({
|
const tool = createClawdbotTools({
|
||||||
agentSessionKey: requesterKey,
|
agentSessionKey: requesterKey,
|
||||||
agentSurface: "discord",
|
agentProvider: "discord",
|
||||||
}).find((candidate) => candidate.name === "sessions_send");
|
}).find((candidate) => candidate.name === "sessions_send");
|
||||||
expect(tool).toBeDefined();
|
expect(tool).toBeDefined();
|
||||||
if (!tool) throw new Error("missing sessions_send tool");
|
if (!tool) throw new Error("missing sessions_send tool");
|
||||||
|
|||||||
@ -22,7 +22,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
|
|
||||||
describe("subagents", () => {
|
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();
|
callGatewayMock.mockReset();
|
||||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||||
let agentCallCount = 0;
|
let agentCallCount = 0;
|
||||||
@ -83,7 +83,7 @@ describe("subagents", () => {
|
|||||||
|
|
||||||
const tool = createClawdbotTools({
|
const tool = createClawdbotTools({
|
||||||
agentSessionKey: "discord:group:req",
|
agentSessionKey: "discord:group:req",
|
||||||
agentSurface: "discord",
|
agentProvider: "discord",
|
||||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||||
if (!tool) throw new Error("missing sessions_spawn tool");
|
if (!tool) throw new Error("missing sessions_spawn tool");
|
||||||
|
|
||||||
@ -103,14 +103,14 @@ describe("subagents", () => {
|
|||||||
| undefined;
|
| undefined;
|
||||||
expect(first?.lane).toBe("subagent");
|
expect(first?.lane).toBe("subagent");
|
||||||
expect(first?.deliver).toBe(false);
|
expect(first?.deliver).toBe(false);
|
||||||
expect(first?.sessionKey?.startsWith("subagent:")).toBe(true);
|
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
|
|
||||||
expect(sendParams).toMatchObject({
|
expect(sendParams).toMatchObject({
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
to: "channel:req",
|
to: "channel:req",
|
||||||
message: "announce now",
|
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 () => {
|
it("sessions_spawn resolves main announce target from sessions.list", async () => {
|
||||||
@ -129,7 +129,7 @@ describe("subagents", () => {
|
|||||||
sessions: [
|
sessions: [
|
||||||
{
|
{
|
||||||
key: "main",
|
key: "main",
|
||||||
lastChannel: "whatsapp",
|
lastProvider: "whatsapp",
|
||||||
lastTo: "+123",
|
lastTo: "+123",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -182,7 +182,7 @@ describe("subagents", () => {
|
|||||||
|
|
||||||
const tool = createClawdbotTools({
|
const tool = createClawdbotTools({
|
||||||
agentSessionKey: "main",
|
agentSessionKey: "main",
|
||||||
agentSurface: "whatsapp",
|
agentProvider: "whatsapp",
|
||||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||||
if (!tool) throw new Error("missing sessions_spawn tool");
|
if (!tool) throw new Error("missing sessions_spawn tool");
|
||||||
|
|
||||||
|
|||||||
@ -16,11 +16,15 @@ import { createSlackTool } from "./tools/slack-tool.js";
|
|||||||
export function createClawdbotTools(options?: {
|
export function createClawdbotTools(options?: {
|
||||||
browserControlUrl?: string;
|
browserControlUrl?: string;
|
||||||
agentSessionKey?: string;
|
agentSessionKey?: string;
|
||||||
agentSurface?: string;
|
agentProvider?: string;
|
||||||
|
agentDir?: string;
|
||||||
sandboxed?: boolean;
|
sandboxed?: boolean;
|
||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
}): AnyAgentTool[] {
|
}): AnyAgentTool[] {
|
||||||
const imageTool = createImageTool({ config: options?.config });
|
const imageTool = createImageTool({
|
||||||
|
config: options?.config,
|
||||||
|
agentDir: options?.agentDir,
|
||||||
|
});
|
||||||
return [
|
return [
|
||||||
createBrowserTool({ defaultControlUrl: options?.browserControlUrl }),
|
createBrowserTool({ defaultControlUrl: options?.browserControlUrl }),
|
||||||
createCanvasTool(),
|
createCanvasTool(),
|
||||||
@ -39,12 +43,12 @@ export function createClawdbotTools(options?: {
|
|||||||
}),
|
}),
|
||||||
createSessionsSendTool({
|
createSessionsSendTool({
|
||||||
agentSessionKey: options?.agentSessionKey,
|
agentSessionKey: options?.agentSessionKey,
|
||||||
agentSurface: options?.agentSurface,
|
agentProvider: options?.agentProvider,
|
||||||
sandboxed: options?.sandboxed,
|
sandboxed: options?.sandboxed,
|
||||||
}),
|
}),
|
||||||
createSessionsSpawnTool({
|
createSessionsSpawnTool({
|
||||||
agentSessionKey: options?.agentSessionKey,
|
agentSessionKey: options?.agentSessionKey,
|
||||||
agentSurface: options?.agentSurface,
|
agentProvider: options?.agentProvider,
|
||||||
sandboxed: options?.sandboxed,
|
sandboxed: options?.sandboxed,
|
||||||
}),
|
}),
|
||||||
...(imageTool ? [imageTool] : []),
|
...(imageTool ? [imageTool] : []),
|
||||||
|
|||||||
@ -31,15 +31,17 @@ export async function resolveApiKeyForProvider(params: {
|
|||||||
profileId?: string;
|
profileId?: string;
|
||||||
preferredProfile?: string;
|
preferredProfile?: string;
|
||||||
store?: AuthProfileStore;
|
store?: AuthProfileStore;
|
||||||
|
agentDir?: string;
|
||||||
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
|
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
|
||||||
const { provider, cfg, profileId, preferredProfile } = params;
|
const { provider, cfg, profileId, preferredProfile } = params;
|
||||||
const store = params.store ?? ensureAuthProfileStore();
|
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
|
||||||
|
|
||||||
if (profileId) {
|
if (profileId) {
|
||||||
const resolved = await resolveApiKeyForProfile({
|
const resolved = await resolveApiKeyForProfile({
|
||||||
cfg,
|
cfg,
|
||||||
store,
|
store,
|
||||||
profileId,
|
profileId,
|
||||||
|
agentDir: params.agentDir,
|
||||||
});
|
});
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
throw new Error(`No credentials found for profile "${profileId}".`);
|
throw new Error(`No credentials found for profile "${profileId}".`);
|
||||||
@ -63,6 +65,7 @@ export async function resolveApiKeyForProvider(params: {
|
|||||||
cfg,
|
cfg,
|
||||||
store,
|
store,
|
||||||
profileId: candidate,
|
profileId: candidate,
|
||||||
|
agentDir: params.agentDir,
|
||||||
});
|
});
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
return {
|
return {
|
||||||
@ -146,6 +149,7 @@ export async function getApiKeyForModel(params: {
|
|||||||
profileId?: string;
|
profileId?: string;
|
||||||
preferredProfile?: string;
|
preferredProfile?: string;
|
||||||
store?: AuthProfileStore;
|
store?: AuthProfileStore;
|
||||||
|
agentDir?: string;
|
||||||
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
|
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
|
||||||
return resolveApiKeyForProvider({
|
return resolveApiKeyForProvider({
|
||||||
provider: params.model.provider,
|
provider: params.model.provider,
|
||||||
@ -153,5 +157,6 @@ export async function getApiKeyForModel(params: {
|
|||||||
profileId: params.profileId,
|
profileId: params.profileId,
|
||||||
preferredProfile: params.preferredProfile,
|
preferredProfile: params.preferredProfile,
|
||||||
store: params.store,
|
store: params.store,
|
||||||
|
agentDir: params.agentDir,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,10 +2,7 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
|
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
|
||||||
import {
|
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||||
ensureClawdbotAgentEnv,
|
|
||||||
resolveClawdbotAgentDir,
|
|
||||||
} from "./agent-paths.js";
|
|
||||||
|
|
||||||
type ModelsConfig = NonNullable<ClawdbotConfig["models"]>;
|
type ModelsConfig = NonNullable<ClawdbotConfig["models"]>;
|
||||||
|
|
||||||
@ -26,15 +23,21 @@ async function readJson(pathname: string): Promise<unknown> {
|
|||||||
|
|
||||||
export async function ensureClawdbotModelsJson(
|
export async function ensureClawdbotModelsJson(
|
||||||
config?: ClawdbotConfig,
|
config?: ClawdbotConfig,
|
||||||
|
agentDirOverride?: string,
|
||||||
): Promise<{ agentDir: string; wrote: boolean }> {
|
): Promise<{ agentDir: string; wrote: boolean }> {
|
||||||
const cfg = config ?? loadConfig();
|
const cfg = config ?? loadConfig();
|
||||||
const providers = cfg.models?.providers;
|
const providers = cfg.models?.providers;
|
||||||
if (!providers || Object.keys(providers).length === 0) {
|
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 mode = cfg.models?.mode ?? DEFAULT_MODE;
|
||||||
const agentDir = ensureClawdbotAgentEnv();
|
const agentDir = agentDirOverride?.trim()
|
||||||
|
? agentDirOverride.trim()
|
||||||
|
: resolveClawdbotAgentDir();
|
||||||
const targetPath = path.join(agentDir, "models.json");
|
const targetPath = path.join(agentDir, "models.json");
|
||||||
|
|
||||||
let mergedProviders = providers;
|
let mergedProviders = providers;
|
||||||
|
|||||||
@ -335,9 +335,10 @@ function resolvePromptSkills(
|
|||||||
export async function compactEmbeddedPiSession(params: {
|
export async function compactEmbeddedPiSession(params: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
surface?: string;
|
messageProvider?: string;
|
||||||
sessionFile: string;
|
sessionFile: string;
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
|
agentDir?: string;
|
||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
skillsSnapshot?: SkillSnapshot;
|
skillsSnapshot?: SkillSnapshot;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
@ -366,7 +367,7 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
(params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
(params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||||
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
||||||
await ensureClawdbotModelsJson(params.config);
|
await ensureClawdbotModelsJson(params.config);
|
||||||
const agentDir = resolveClawdbotAgentDir();
|
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
||||||
const { model, error, authStorage, modelRegistry } = resolveModel(
|
const { model, error, authStorage, modelRegistry } = resolveModel(
|
||||||
provider,
|
provider,
|
||||||
modelId,
|
modelId,
|
||||||
@ -440,8 +441,9 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
elevated: params.bashElevated,
|
elevated: params.bashElevated,
|
||||||
},
|
},
|
||||||
sandbox,
|
sandbox,
|
||||||
surface: params.surface,
|
messageProvider: params.messageProvider,
|
||||||
sessionKey: params.sessionKey ?? params.sessionId,
|
sessionKey: params.sessionKey ?? params.sessionId,
|
||||||
|
agentDir,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
});
|
});
|
||||||
const machineName = await getMachineDisplayName();
|
const machineName = await getMachineDisplayName();
|
||||||
@ -544,9 +546,10 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
export async function runEmbeddedPiAgent(params: {
|
export async function runEmbeddedPiAgent(params: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
surface?: string;
|
messageProvider?: string;
|
||||||
sessionFile: string;
|
sessionFile: string;
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
|
agentDir?: string;
|
||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
skillsSnapshot?: SkillSnapshot;
|
skillsSnapshot?: SkillSnapshot;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@ -601,7 +604,7 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
(params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
(params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||||
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
||||||
await ensureClawdbotModelsJson(params.config);
|
await ensureClawdbotModelsJson(params.config);
|
||||||
const agentDir = resolveClawdbotAgentDir();
|
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
||||||
const { model, error, authStorage, modelRegistry } = resolveModel(
|
const { model, error, authStorage, modelRegistry } = resolveModel(
|
||||||
provider,
|
provider,
|
||||||
modelId,
|
modelId,
|
||||||
@ -610,7 +613,7 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
if (!model) {
|
if (!model) {
|
||||||
throw new Error(error ?? `Unknown model: ${provider}/${modelId}`);
|
throw new Error(error ?? `Unknown model: ${provider}/${modelId}`);
|
||||||
}
|
}
|
||||||
const authStore = ensureAuthProfileStore();
|
const authStore = ensureAuthProfileStore(agentDir);
|
||||||
const explicitProfileId = params.authProfileId?.trim();
|
const explicitProfileId = params.authProfileId?.trim();
|
||||||
const profileOrder = resolveAuthProfileOrder({
|
const profileOrder = resolveAuthProfileOrder({
|
||||||
cfg: params.config,
|
cfg: params.config,
|
||||||
@ -678,7 +681,7 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
attemptedThinking.add(thinkLevel);
|
attemptedThinking.add(thinkLevel);
|
||||||
|
|
||||||
log.debug(
|
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 });
|
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
||||||
@ -734,8 +737,9 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
elevated: params.bashElevated,
|
elevated: params.bashElevated,
|
||||||
},
|
},
|
||||||
sandbox,
|
sandbox,
|
||||||
surface: params.surface,
|
messageProvider: params.messageProvider,
|
||||||
sessionKey: params.sessionKey ?? params.sessionId,
|
sessionKey: params.sessionKey ?? params.sessionId,
|
||||||
|
agentDir,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
});
|
});
|
||||||
const machineName = await getMachineDisplayName();
|
const machineName = await getMachineDisplayName();
|
||||||
|
|||||||
@ -100,24 +100,26 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
expect(offenders).toEqual([]);
|
expect(offenders).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scopes discord tool to discord surface", () => {
|
it("scopes discord tool to discord provider", () => {
|
||||||
const other = createClawdbotCodingTools({ surface: "whatsapp" });
|
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
|
||||||
expect(other.some((tool) => tool.name === "discord")).toBe(false);
|
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);
|
expect(discord.some((tool) => tool.name === "discord")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scopes slack tool to slack surface", () => {
|
it("scopes slack tool to slack provider", () => {
|
||||||
const other = createClawdbotCodingTools({ surface: "whatsapp" });
|
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
|
||||||
expect(other.some((tool) => tool.name === "slack")).toBe(false);
|
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);
|
expect(slack.some((tool) => tool.name === "slack")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters session tools for sub-agent sessions by default", () => {
|
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));
|
const names = new Set(tools.map((tool) => tool.name));
|
||||||
expect(names.has("sessions_list")).toBe(false);
|
expect(names.has("sessions_list")).toBe(false);
|
||||||
expect(names.has("sessions_history")).toBe(false);
|
expect(names.has("sessions_history")).toBe(false);
|
||||||
@ -131,7 +133,7 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
|
|
||||||
it("supports allow-only sub-agent tool policy", () => {
|
it("supports allow-only sub-agent tool policy", () => {
|
||||||
const tools = createClawdbotCodingTools({
|
const tools = createClawdbotCodingTools({
|
||||||
sessionKey: "subagent:test",
|
sessionKey: "agent:main:subagent:test",
|
||||||
// Intentionally partial config; only fields used by pi-tools are provided.
|
// Intentionally partial config; only fields used by pi-tools are provided.
|
||||||
config: {
|
config: {
|
||||||
agent: {
|
agent: {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { detectMime } from "../media/mime.js";
|
import { detectMime } from "../media/mime.js";
|
||||||
|
import { isSubagentSessionKey } from "../routing/session-key.js";
|
||||||
import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js";
|
import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js";
|
||||||
import {
|
import {
|
||||||
type BashToolDefaults,
|
type BashToolDefaults,
|
||||||
@ -340,11 +341,6 @@ const DEFAULT_SUBAGENT_TOOL_DENY = [
|
|||||||
"sessions_spawn",
|
"sessions_spawn",
|
||||||
];
|
];
|
||||||
|
|
||||||
function isSubagentSessionKey(sessionKey?: string): boolean {
|
|
||||||
const key = sessionKey?.trim().toLowerCase() ?? "";
|
|
||||||
return key.startsWith("subagent:");
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy {
|
function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy {
|
||||||
const configured = cfg?.agent?.subagents?.tools;
|
const configured = cfg?.agent?.subagents?.tools;
|
||||||
const deny = [
|
const deny = [
|
||||||
@ -488,28 +484,31 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSurface(surface?: string): string | undefined {
|
function normalizeMessageProvider(
|
||||||
const trimmed = surface?.trim().toLowerCase();
|
messageProvider?: string,
|
||||||
|
): string | undefined {
|
||||||
|
const trimmed = messageProvider?.trim().toLowerCase();
|
||||||
return trimmed ? trimmed : undefined;
|
return trimmed ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldIncludeDiscordTool(surface?: string): boolean {
|
function shouldIncludeDiscordTool(messageProvider?: string): boolean {
|
||||||
const normalized = normalizeSurface(surface);
|
const normalized = normalizeMessageProvider(messageProvider);
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
return normalized === "discord" || normalized.startsWith("discord:");
|
return normalized === "discord" || normalized.startsWith("discord:");
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldIncludeSlackTool(surface?: string): boolean {
|
function shouldIncludeSlackTool(messageProvider?: string): boolean {
|
||||||
const normalized = normalizeSurface(surface);
|
const normalized = normalizeMessageProvider(messageProvider);
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
return normalized === "slack" || normalized.startsWith("slack:");
|
return normalized === "slack" || normalized.startsWith("slack:");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createClawdbotCodingTools(options?: {
|
export function createClawdbotCodingTools(options?: {
|
||||||
bash?: BashToolDefaults & ProcessToolDefaults;
|
bash?: BashToolDefaults & ProcessToolDefaults;
|
||||||
surface?: string;
|
messageProvider?: string;
|
||||||
sandbox?: SandboxContext | null;
|
sandbox?: SandboxContext | null;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
|
agentDir?: string;
|
||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
}): AnyAgentTool[] {
|
}): AnyAgentTool[] {
|
||||||
const bashToolName = "bash";
|
const bashToolName = "bash";
|
||||||
@ -555,13 +554,14 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
...createClawdbotTools({
|
...createClawdbotTools({
|
||||||
browserControlUrl: sandbox?.browser?.controlUrl,
|
browserControlUrl: sandbox?.browser?.controlUrl,
|
||||||
agentSessionKey: options?.sessionKey,
|
agentSessionKey: options?.sessionKey,
|
||||||
agentSurface: options?.surface,
|
agentProvider: options?.messageProvider,
|
||||||
|
agentDir: options?.agentDir,
|
||||||
sandboxed: !!sandbox,
|
sandboxed: !!sandbox,
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
const allowDiscord = shouldIncludeDiscordTool(options?.surface);
|
const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider);
|
||||||
const allowSlack = shouldIncludeSlackTool(options?.surface);
|
const allowSlack = shouldIncludeSlackTool(options?.messageProvider);
|
||||||
const filtered = tools.filter((tool) => {
|
const filtered = tools.filter((tool) => {
|
||||||
if (tool.name === "discord") return allowDiscord;
|
if (tool.name === "discord") return allowDiscord;
|
||||||
if (tool.name === "slack") return allowSlack;
|
if (tool.name === "slack") return allowSlack;
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import { Type } from "@sinclair/typebox";
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { resolveUserPath } from "../../utils.js";
|
import { resolveUserPath } from "../../utils.js";
|
||||||
import { loadWebMedia } from "../../web/media.js";
|
import { loadWebMedia } from "../../web/media.js";
|
||||||
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
|
||||||
import { getApiKeyForModel } from "../model-auth.js";
|
import { getApiKeyForModel } from "../model-auth.js";
|
||||||
import { runWithImageModelFallback } from "../model-fallback.js";
|
import { runWithImageModelFallback } from "../model-fallback.js";
|
||||||
import { ensureClawdbotModelsJson } from "../models-config.js";
|
import { ensureClawdbotModelsJson } from "../models-config.js";
|
||||||
@ -78,15 +77,15 @@ function buildImageContext(
|
|||||||
|
|
||||||
async function runImagePrompt(params: {
|
async function runImagePrompt(params: {
|
||||||
cfg?: ClawdbotConfig;
|
cfg?: ClawdbotConfig;
|
||||||
|
agentDir: string;
|
||||||
modelOverride?: string;
|
modelOverride?: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
base64: string;
|
base64: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
}): Promise<{ text: string; provider: string; model: string }> {
|
}): Promise<{ text: string; provider: string; model: string }> {
|
||||||
const agentDir = resolveClawdbotAgentDir();
|
await ensureClawdbotModelsJson(params.cfg, params.agentDir);
|
||||||
await ensureClawdbotModelsJson(params.cfg);
|
const authStorage = discoverAuthStorage(params.agentDir);
|
||||||
const authStorage = discoverAuthStorage(agentDir);
|
const modelRegistry = discoverModels(authStorage, params.agentDir);
|
||||||
const modelRegistry = discoverModels(authStorage, agentDir);
|
|
||||||
|
|
||||||
const result = await runWithImageModelFallback({
|
const result = await runWithImageModelFallback({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
@ -104,6 +103,7 @@ async function runImagePrompt(params: {
|
|||||||
const apiKeyInfo = await getApiKeyForModel({
|
const apiKeyInfo = await getApiKeyForModel({
|
||||||
model,
|
model,
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
|
agentDir: params.agentDir,
|
||||||
});
|
});
|
||||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||||
const context = buildImageContext(
|
const context = buildImageContext(
|
||||||
@ -130,8 +130,13 @@ async function runImagePrompt(params: {
|
|||||||
|
|
||||||
export function createImageTool(options?: {
|
export function createImageTool(options?: {
|
||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
|
agentDir?: string;
|
||||||
}): AnyAgentTool | null {
|
}): AnyAgentTool | null {
|
||||||
if (!ensureImageToolConfigured(options?.config)) return null;
|
if (!ensureImageToolConfigured(options?.config)) return null;
|
||||||
|
const agentDir = options?.agentDir;
|
||||||
|
if (!agentDir?.trim()) {
|
||||||
|
throw new Error("createImageTool requires agentDir when enabled");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
label: "Image",
|
label: "Image",
|
||||||
name: "image",
|
name: "image",
|
||||||
@ -175,6 +180,7 @@ export function createImageTool(options?: {
|
|||||||
const base64 = media.buffer.toString("base64");
|
const base64 = media.buffer.toString("base64");
|
||||||
const result = await runImagePrompt({
|
const result = await runImagePrompt({
|
||||||
cfg: options?.config,
|
cfg: options?.config,
|
||||||
|
agentDir,
|
||||||
modelOverride,
|
modelOverride,
|
||||||
prompt: promptRaw,
|
prompt: promptRaw,
|
||||||
base64,
|
base64,
|
||||||
|
|||||||
52
src/agents/tools/sessions-announce-target.test.ts
Normal file
52
src/agents/tools/sessions-announce-target.test.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -7,9 +7,12 @@ export async function resolveAnnounceTarget(params: {
|
|||||||
displayKey: string;
|
displayKey: string;
|
||||||
}): Promise<AnnounceTarget | null> {
|
}): Promise<AnnounceTarget | null> {
|
||||||
const parsed = resolveAnnounceTargetFromKey(params.sessionKey);
|
const parsed = resolveAnnounceTargetFromKey(params.sessionKey);
|
||||||
if (parsed) return parsed;
|
|
||||||
const parsedDisplay = resolveAnnounceTargetFromKey(params.displayKey);
|
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 {
|
try {
|
||||||
const list = (await callGateway({
|
const list = (await callGateway({
|
||||||
@ -24,13 +27,17 @@ export async function resolveAnnounceTarget(params: {
|
|||||||
const match =
|
const match =
|
||||||
sessions.find((entry) => entry?.key === params.sessionKey) ??
|
sessions.find((entry) => entry?.key === params.sessionKey) ??
|
||||||
sessions.find((entry) => entry?.key === params.displayKey);
|
sessions.find((entry) => entry?.key === params.displayKey);
|
||||||
const channel =
|
const provider =
|
||||||
typeof match?.lastChannel === "string" ? match.lastChannel : undefined;
|
typeof match?.lastProvider === "string" ? match.lastProvider : undefined;
|
||||||
const to = typeof match?.lastTo === "string" ? match.lastTo : 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 {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,8 +58,8 @@ export function classifySessionKind(params: {
|
|||||||
export function deriveProvider(params: {
|
export function deriveProvider(params: {
|
||||||
key: string;
|
key: string;
|
||||||
kind: SessionKind;
|
kind: SessionKind;
|
||||||
surface?: string | null;
|
provider?: string | null;
|
||||||
lastChannel?: string | null;
|
lastProvider?: string | null;
|
||||||
}): string {
|
}): string {
|
||||||
if (
|
if (
|
||||||
params.kind === "cron" ||
|
params.kind === "cron" ||
|
||||||
@ -67,10 +67,10 @@ export function deriveProvider(params: {
|
|||||||
params.kind === "node"
|
params.kind === "node"
|
||||||
)
|
)
|
||||||
return "internal";
|
return "internal";
|
||||||
const surface = normalizeKey(params.surface ?? undefined);
|
const provider = normalizeKey(params.provider ?? undefined);
|
||||||
if (surface) return surface;
|
if (provider) return provider;
|
||||||
const lastChannel = normalizeKey(params.lastChannel ?? undefined);
|
const lastProvider = normalizeKey(params.lastProvider ?? undefined);
|
||||||
if (lastChannel) return lastChannel;
|
if (lastProvider) return lastProvider;
|
||||||
const parts = params.key.split(":").filter(Boolean);
|
const parts = params.key.split(":").filter(Boolean);
|
||||||
if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) {
|
if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) {
|
||||||
return parts[0];
|
return parts[0];
|
||||||
|
|||||||
@ -2,6 +2,11 @@ import { Type } from "@sinclair/typebox";
|
|||||||
|
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { callGateway } from "../../gateway/call.js";
|
import { callGateway } from "../../gateway/call.js";
|
||||||
|
import {
|
||||||
|
isSubagentSessionKey,
|
||||||
|
normalizeAgentId,
|
||||||
|
parseAgentSessionKey,
|
||||||
|
} from "../../routing/session-key.js";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import { jsonResult, readStringParam } from "./common.js";
|
import { jsonResult, readStringParam } from "./common.js";
|
||||||
import {
|
import {
|
||||||
@ -78,7 +83,7 @@ export function createSessionsHistoryTool(opts?: {
|
|||||||
opts?.sandboxed === true &&
|
opts?.sandboxed === true &&
|
||||||
visibility === "spawned" &&
|
visibility === "spawned" &&
|
||||||
requesterInternalKey &&
|
requesterInternalKey &&
|
||||||
!requesterInternalKey.toLowerCase().startsWith("subagent:");
|
!isSubagentSessionKey(requesterInternalKey);
|
||||||
if (restrictToSpawned) {
|
if (restrictToSpawned) {
|
||||||
const ok = await isSpawnedSessionAllowed({
|
const ok = await isSpawnedSessionAllowed({
|
||||||
requesterSessionKey: requesterInternalKey,
|
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 =
|
const limit =
|
||||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||||
? Math.max(1, Math.floor(params.limit))
|
? Math.max(1, Math.floor(params.limit))
|
||||||
|
|||||||
43
src/agents/tools/sessions-list-tool.gating.test.ts
Normal file
43
src/agents/tools/sessions-list-tool.gating.test.ts
Normal file
@ -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<typeof import("../../config/config.js")>();
|
||||||
|
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" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -4,6 +4,11 @@ import { Type } from "@sinclair/typebox";
|
|||||||
|
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { callGateway } from "../../gateway/call.js";
|
import { callGateway } from "../../gateway/call.js";
|
||||||
|
import {
|
||||||
|
isSubagentSessionKey,
|
||||||
|
normalizeAgentId,
|
||||||
|
parseAgentSessionKey,
|
||||||
|
} from "../../routing/session-key.js";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import { jsonResult, readStringArrayParam } from "./common.js";
|
import { jsonResult, readStringArrayParam } from "./common.js";
|
||||||
import {
|
import {
|
||||||
@ -31,8 +36,9 @@ type SessionListRow = {
|
|||||||
systemSent?: boolean;
|
systemSent?: boolean;
|
||||||
abortedLastRun?: boolean;
|
abortedLastRun?: boolean;
|
||||||
sendPolicy?: string;
|
sendPolicy?: string;
|
||||||
lastChannel?: string;
|
lastProvider?: string;
|
||||||
lastTo?: string;
|
lastTo?: string;
|
||||||
|
lastAccountId?: string;
|
||||||
transcriptPath?: string;
|
transcriptPath?: string;
|
||||||
messages?: unknown[];
|
messages?: unknown[];
|
||||||
};
|
};
|
||||||
@ -76,7 +82,7 @@ export function createSessionsListTool(opts?: {
|
|||||||
opts?.sandboxed === true &&
|
opts?.sandboxed === true &&
|
||||||
visibility === "spawned" &&
|
visibility === "spawned" &&
|
||||||
requesterInternalKey &&
|
requesterInternalKey &&
|
||||||
!requesterInternalKey.toLowerCase().startsWith("subagent:");
|
!isSubagentSessionKey(requesterInternalKey);
|
||||||
|
|
||||||
const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) =>
|
const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) =>
|
||||||
value.trim().toLowerCase(),
|
value.trim().toLowerCase(),
|
||||||
@ -120,12 +126,43 @@ export function createSessionsListTool(opts?: {
|
|||||||
|
|
||||||
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
|
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
|
||||||
const storePath = typeof list?.path === "string" ? list.path : undefined;
|
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[] = [];
|
const rows: SessionListRow[] = [];
|
||||||
|
|
||||||
for (const entry of sessions) {
|
for (const entry of sessions) {
|
||||||
if (!entry || typeof entry !== "object") continue;
|
if (!entry || typeof entry !== "object") continue;
|
||||||
const key = typeof entry.key === "string" ? entry.key : "";
|
const key = typeof entry.key === "string" ? entry.key : "";
|
||||||
if (!key) continue;
|
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 === "unknown") continue;
|
||||||
if (key === "global" && alias !== "global") continue;
|
if (key === "global" && alias !== "global") continue;
|
||||||
|
|
||||||
@ -140,15 +177,21 @@ export function createSessionsListTool(opts?: {
|
|||||||
mainKey,
|
mainKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const surface =
|
const entryProvider =
|
||||||
typeof entry.surface === "string" ? entry.surface : undefined;
|
typeof entry.provider === "string" ? entry.provider : undefined;
|
||||||
const lastChannel =
|
const lastProvider =
|
||||||
typeof entry.lastChannel === "string" ? entry.lastChannel : undefined;
|
typeof entry.lastProvider === "string"
|
||||||
const provider = deriveProvider({
|
? entry.lastProvider
|
||||||
|
: undefined;
|
||||||
|
const lastAccountId =
|
||||||
|
typeof entry.lastAccountId === "string"
|
||||||
|
? entry.lastAccountId
|
||||||
|
: undefined;
|
||||||
|
const derivedProvider = deriveProvider({
|
||||||
key,
|
key,
|
||||||
kind,
|
kind,
|
||||||
surface,
|
provider: entryProvider,
|
||||||
lastChannel,
|
lastProvider,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionId =
|
const sessionId =
|
||||||
@ -161,7 +204,7 @@ export function createSessionsListTool(opts?: {
|
|||||||
const row: SessionListRow = {
|
const row: SessionListRow = {
|
||||||
key: displayKey,
|
key: displayKey,
|
||||||
kind,
|
kind,
|
||||||
provider,
|
provider: derivedProvider,
|
||||||
displayName:
|
displayName:
|
||||||
typeof entry.displayName === "string"
|
typeof entry.displayName === "string"
|
||||||
? entry.displayName
|
? entry.displayName
|
||||||
@ -196,8 +239,9 @@ export function createSessionsListTool(opts?: {
|
|||||||
: undefined,
|
: undefined,
|
||||||
sendPolicy:
|
sendPolicy:
|
||||||
typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined,
|
typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined,
|
||||||
lastChannel,
|
lastProvider,
|
||||||
lastTo: typeof entry.lastTo === "string" ? entry.lastTo : undefined,
|
lastTo: typeof entry.lastTo === "string" ? entry.lastTo : undefined,
|
||||||
|
lastAccountId,
|
||||||
transcriptPath,
|
transcriptPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -6,33 +6,38 @@ const DEFAULT_PING_PONG_TURNS = 5;
|
|||||||
const MAX_PING_PONG_TURNS = 5;
|
const MAX_PING_PONG_TURNS = 5;
|
||||||
|
|
||||||
export type AnnounceTarget = {
|
export type AnnounceTarget = {
|
||||||
channel: string;
|
provider: string;
|
||||||
to: string;
|
to: string;
|
||||||
|
accountId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveAnnounceTargetFromKey(
|
export function resolveAnnounceTargetFromKey(
|
||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
): AnnounceTarget | null {
|
): 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;
|
if (parts.length < 3) return null;
|
||||||
const [surface, kind, ...rest] = parts;
|
const [providerRaw, kind, ...rest] = parts;
|
||||||
if (kind !== "group" && kind !== "channel") return null;
|
if (kind !== "group" && kind !== "channel") return null;
|
||||||
const id = rest.join(":").trim();
|
const id = rest.join(":").trim();
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
if (!surface) return null;
|
if (!providerRaw) return null;
|
||||||
const channel = surface.toLowerCase();
|
const provider = providerRaw.toLowerCase();
|
||||||
if (channel === "discord") {
|
if (provider === "discord") {
|
||||||
return { channel, to: `channel:${id}` };
|
return { provider, to: `channel:${id}` };
|
||||||
}
|
}
|
||||||
if (channel === "signal") {
|
if (provider === "signal") {
|
||||||
return { channel, to: `group:${id}` };
|
return { provider, to: `group:${id}` };
|
||||||
}
|
}
|
||||||
return { channel, to: id };
|
return { provider, to: id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAgentToAgentMessageContext(params: {
|
export function buildAgentToAgentMessageContext(params: {
|
||||||
requesterSessionKey?: string;
|
requesterSessionKey?: string;
|
||||||
requesterSurface?: string;
|
requesterProvider?: string;
|
||||||
targetSessionKey: string;
|
targetSessionKey: string;
|
||||||
}) {
|
}) {
|
||||||
const lines = [
|
const lines = [
|
||||||
@ -40,8 +45,8 @@ export function buildAgentToAgentMessageContext(params: {
|
|||||||
params.requesterSessionKey
|
params.requesterSessionKey
|
||||||
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
|
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
|
||||||
: undefined,
|
: undefined,
|
||||||
params.requesterSurface
|
params.requesterProvider
|
||||||
? `Agent 1 (requester) surface: ${params.requesterSurface}.`
|
? `Agent 1 (requester) provider: ${params.requesterProvider}.`
|
||||||
: undefined,
|
: undefined,
|
||||||
`Agent 2 (target) session: ${params.targetSessionKey}.`,
|
`Agent 2 (target) session: ${params.targetSessionKey}.`,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
@ -50,9 +55,9 @@ export function buildAgentToAgentMessageContext(params: {
|
|||||||
|
|
||||||
export function buildAgentToAgentReplyContext(params: {
|
export function buildAgentToAgentReplyContext(params: {
|
||||||
requesterSessionKey?: string;
|
requesterSessionKey?: string;
|
||||||
requesterSurface?: string;
|
requesterProvider?: string;
|
||||||
targetSessionKey: string;
|
targetSessionKey: string;
|
||||||
targetChannel?: string;
|
targetProvider?: string;
|
||||||
currentRole: "requester" | "target";
|
currentRole: "requester" | "target";
|
||||||
turn: number;
|
turn: number;
|
||||||
maxTurns: number;
|
maxTurns: number;
|
||||||
@ -68,12 +73,12 @@ export function buildAgentToAgentReplyContext(params: {
|
|||||||
params.requesterSessionKey
|
params.requesterSessionKey
|
||||||
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
|
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
|
||||||
: undefined,
|
: undefined,
|
||||||
params.requesterSurface
|
params.requesterProvider
|
||||||
? `Agent 1 (requester) surface: ${params.requesterSurface}.`
|
? `Agent 1 (requester) provider: ${params.requesterProvider}.`
|
||||||
: undefined,
|
: undefined,
|
||||||
`Agent 2 (target) session: ${params.targetSessionKey}.`,
|
`Agent 2 (target) session: ${params.targetSessionKey}.`,
|
||||||
params.targetChannel
|
params.targetProvider
|
||||||
? `Agent 2 (target) surface: ${params.targetChannel}.`
|
? `Agent 2 (target) provider: ${params.targetProvider}.`
|
||||||
: undefined,
|
: undefined,
|
||||||
`If you want to stop the ping-pong, reply exactly "${REPLY_SKIP_TOKEN}".`,
|
`If you want to stop the ping-pong, reply exactly "${REPLY_SKIP_TOKEN}".`,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
@ -82,9 +87,9 @@ export function buildAgentToAgentReplyContext(params: {
|
|||||||
|
|
||||||
export function buildAgentToAgentAnnounceContext(params: {
|
export function buildAgentToAgentAnnounceContext(params: {
|
||||||
requesterSessionKey?: string;
|
requesterSessionKey?: string;
|
||||||
requesterSurface?: string;
|
requesterProvider?: string;
|
||||||
targetSessionKey: string;
|
targetSessionKey: string;
|
||||||
targetChannel?: string;
|
targetProvider?: string;
|
||||||
originalMessage: string;
|
originalMessage: string;
|
||||||
roundOneReply?: string;
|
roundOneReply?: string;
|
||||||
latestReply?: string;
|
latestReply?: string;
|
||||||
@ -94,12 +99,12 @@ export function buildAgentToAgentAnnounceContext(params: {
|
|||||||
params.requesterSessionKey
|
params.requesterSessionKey
|
||||||
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
|
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
|
||||||
: undefined,
|
: undefined,
|
||||||
params.requesterSurface
|
params.requesterProvider
|
||||||
? `Agent 1 (requester) surface: ${params.requesterSurface}.`
|
? `Agent 1 (requester) provider: ${params.requesterProvider}.`
|
||||||
: undefined,
|
: undefined,
|
||||||
`Agent 2 (target) session: ${params.targetSessionKey}.`,
|
`Agent 2 (target) session: ${params.targetSessionKey}.`,
|
||||||
params.targetChannel
|
params.targetProvider
|
||||||
? `Agent 2 (target) surface: ${params.targetChannel}.`
|
? `Agent 2 (target) provider: ${params.targetProvider}.`
|
||||||
: undefined,
|
: undefined,
|
||||||
`Original request: ${params.originalMessage}`,
|
`Original request: ${params.originalMessage}`,
|
||||||
params.roundOneReply
|
params.roundOneReply
|
||||||
@ -109,7 +114,7 @@ export function buildAgentToAgentAnnounceContext(params: {
|
|||||||
? `Latest reply: ${params.latestReply}`
|
? `Latest reply: ${params.latestReply}`
|
||||||
: "Latest reply: (not available).",
|
: "Latest reply: (not available).",
|
||||||
`If you want to remain silent, reply exactly "${ANNOUNCE_SKIP_TOKEN}".`,
|
`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.",
|
"After this reply, the agent-to-agent conversation is over.",
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
|
|||||||
43
src/agents/tools/sessions-send-tool.gating.test.ts
Normal file
43
src/agents/tools/sessions-send-tool.gating.test.ts
Normal file
@ -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<typeof import("../../config/config.js")>();
|
||||||
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -4,6 +4,11 @@ import { Type } from "@sinclair/typebox";
|
|||||||
|
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { callGateway } from "../../gateway/call.js";
|
import { callGateway } from "../../gateway/call.js";
|
||||||
|
import {
|
||||||
|
isSubagentSessionKey,
|
||||||
|
normalizeAgentId,
|
||||||
|
parseAgentSessionKey,
|
||||||
|
} from "../../routing/session-key.js";
|
||||||
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
|
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import { jsonResult, readStringParam } from "./common.js";
|
import { jsonResult, readStringParam } from "./common.js";
|
||||||
@ -32,7 +37,7 @@ const SessionsSendToolSchema = Type.Object({
|
|||||||
|
|
||||||
export function createSessionsSendTool(opts?: {
|
export function createSessionsSendTool(opts?: {
|
||||||
agentSessionKey?: string;
|
agentSessionKey?: string;
|
||||||
agentSurface?: string;
|
agentProvider?: string;
|
||||||
sandboxed?: boolean;
|
sandboxed?: boolean;
|
||||||
}): AnyAgentTool {
|
}): AnyAgentTool {
|
||||||
return {
|
return {
|
||||||
@ -67,7 +72,7 @@ export function createSessionsSendTool(opts?: {
|
|||||||
opts?.sandboxed === true &&
|
opts?.sandboxed === true &&
|
||||||
visibility === "spawned" &&
|
visibility === "spawned" &&
|
||||||
requesterInternalKey &&
|
requesterInternalKey &&
|
||||||
!requesterInternalKey.toLowerCase().startsWith("subagent:");
|
!isSubagentSessionKey(requesterInternalKey);
|
||||||
if (restrictToSpawned) {
|
if (restrictToSpawned) {
|
||||||
try {
|
try {
|
||||||
const list = (await callGateway({
|
const list = (await callGateway({
|
||||||
@ -120,9 +125,55 @@ export function createSessionsSendTool(opts?: {
|
|||||||
alias,
|
alias,
|
||||||
mainKey,
|
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({
|
const agentMessageContext = buildAgentToAgentMessageContext({
|
||||||
requesterSessionKey: opts?.agentSessionKey,
|
requesterSessionKey: opts?.agentSessionKey,
|
||||||
requesterSurface: opts?.agentSurface,
|
requesterProvider: opts?.agentProvider,
|
||||||
targetSessionKey: displayKey,
|
targetSessionKey: displayKey,
|
||||||
});
|
});
|
||||||
const sendParams = {
|
const sendParams = {
|
||||||
@ -134,7 +185,7 @@ export function createSessionsSendTool(opts?: {
|
|||||||
extraSystemPrompt: agentMessageContext,
|
extraSystemPrompt: agentMessageContext,
|
||||||
};
|
};
|
||||||
const requesterSessionKey = opts?.agentSessionKey;
|
const requesterSessionKey = opts?.agentSessionKey;
|
||||||
const requesterSurface = opts?.agentSurface;
|
const requesterProvider = opts?.agentProvider;
|
||||||
const maxPingPongTurns = resolvePingPongTurns(cfg);
|
const maxPingPongTurns = resolvePingPongTurns(cfg);
|
||||||
|
|
||||||
const runAgentToAgentFlow = async (
|
const runAgentToAgentFlow = async (
|
||||||
@ -166,7 +217,7 @@ export function createSessionsSendTool(opts?: {
|
|||||||
sessionKey: resolvedKey,
|
sessionKey: resolvedKey,
|
||||||
displayKey,
|
displayKey,
|
||||||
});
|
});
|
||||||
const targetChannel = announceTarget?.channel ?? "unknown";
|
const targetProvider = announceTarget?.provider ?? "unknown";
|
||||||
if (
|
if (
|
||||||
maxPingPongTurns > 0 &&
|
maxPingPongTurns > 0 &&
|
||||||
requesterSessionKey &&
|
requesterSessionKey &&
|
||||||
@ -182,9 +233,9 @@ export function createSessionsSendTool(opts?: {
|
|||||||
: "target";
|
: "target";
|
||||||
const replyPrompt = buildAgentToAgentReplyContext({
|
const replyPrompt = buildAgentToAgentReplyContext({
|
||||||
requesterSessionKey,
|
requesterSessionKey,
|
||||||
requesterSurface,
|
requesterProvider,
|
||||||
targetSessionKey: displayKey,
|
targetSessionKey: displayKey,
|
||||||
targetChannel,
|
targetProvider,
|
||||||
currentRole,
|
currentRole,
|
||||||
turn,
|
turn,
|
||||||
maxTurns: maxPingPongTurns,
|
maxTurns: maxPingPongTurns,
|
||||||
@ -208,9 +259,9 @@ export function createSessionsSendTool(opts?: {
|
|||||||
}
|
}
|
||||||
const announcePrompt = buildAgentToAgentAnnounceContext({
|
const announcePrompt = buildAgentToAgentAnnounceContext({
|
||||||
requesterSessionKey,
|
requesterSessionKey,
|
||||||
requesterSurface,
|
requesterProvider,
|
||||||
targetSessionKey: displayKey,
|
targetSessionKey: displayKey,
|
||||||
targetChannel,
|
targetProvider,
|
||||||
originalMessage: message,
|
originalMessage: message,
|
||||||
roundOneReply: primaryReply,
|
roundOneReply: primaryReply,
|
||||||
latestReply,
|
latestReply,
|
||||||
@ -233,7 +284,8 @@ export function createSessionsSendTool(opts?: {
|
|||||||
params: {
|
params: {
|
||||||
to: announceTarget.to,
|
to: announceTarget.to,
|
||||||
message: announceReply.trim(),
|
message: announceReply.trim(),
|
||||||
provider: announceTarget.channel,
|
provider: announceTarget.provider,
|
||||||
|
accountId: announceTarget.accountId,
|
||||||
idempotencyKey: crypto.randomUUID(),
|
idempotencyKey: crypto.randomUUID(),
|
||||||
},
|
},
|
||||||
timeoutMs: 10_000,
|
timeoutMs: 10_000,
|
||||||
|
|||||||
@ -4,6 +4,11 @@ import { Type } from "@sinclair/typebox";
|
|||||||
|
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { callGateway } from "../../gateway/call.js";
|
import { callGateway } from "../../gateway/call.js";
|
||||||
|
import {
|
||||||
|
isSubagentSessionKey,
|
||||||
|
normalizeAgentId,
|
||||||
|
parseAgentSessionKey,
|
||||||
|
} from "../../routing/session-key.js";
|
||||||
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
|
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import { jsonResult, readStringParam } from "./common.js";
|
import { jsonResult, readStringParam } from "./common.js";
|
||||||
@ -26,7 +31,7 @@ const SessionsSpawnToolSchema = Type.Object({
|
|||||||
|
|
||||||
function buildSubagentSystemPrompt(params: {
|
function buildSubagentSystemPrompt(params: {
|
||||||
requesterSessionKey?: string;
|
requesterSessionKey?: string;
|
||||||
requesterSurface?: string;
|
requesterProvider?: string;
|
||||||
childSessionKey: string;
|
childSessionKey: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
}) {
|
}) {
|
||||||
@ -36,8 +41,8 @@ function buildSubagentSystemPrompt(params: {
|
|||||||
params.requesterSessionKey
|
params.requesterSessionKey
|
||||||
? `Requester session: ${params.requesterSessionKey}.`
|
? `Requester session: ${params.requesterSessionKey}.`
|
||||||
: undefined,
|
: undefined,
|
||||||
params.requesterSurface
|
params.requesterProvider
|
||||||
? `Requester surface: ${params.requesterSurface}.`
|
? `Requester provider: ${params.requesterProvider}.`
|
||||||
: undefined,
|
: undefined,
|
||||||
`Your session: ${params.childSessionKey}.`,
|
`Your session: ${params.childSessionKey}.`,
|
||||||
"Run the task. Provide a clear final answer (plain text).",
|
"Run the task. Provide a clear final answer (plain text).",
|
||||||
@ -48,7 +53,7 @@ function buildSubagentSystemPrompt(params: {
|
|||||||
|
|
||||||
function buildSubagentAnnouncePrompt(params: {
|
function buildSubagentAnnouncePrompt(params: {
|
||||||
requesterSessionKey?: string;
|
requesterSessionKey?: string;
|
||||||
requesterSurface?: string;
|
requesterProvider?: string;
|
||||||
announceChannel: string;
|
announceChannel: string;
|
||||||
task: string;
|
task: string;
|
||||||
subagentReply?: string;
|
subagentReply?: string;
|
||||||
@ -58,16 +63,16 @@ function buildSubagentAnnouncePrompt(params: {
|
|||||||
params.requesterSessionKey
|
params.requesterSessionKey
|
||||||
? `Requester session: ${params.requesterSessionKey}.`
|
? `Requester session: ${params.requesterSessionKey}.`
|
||||||
: undefined,
|
: undefined,
|
||||||
params.requesterSurface
|
params.requesterProvider
|
||||||
? `Requester surface: ${params.requesterSurface}.`
|
? `Requester provider: ${params.requesterProvider}.`
|
||||||
: undefined,
|
: undefined,
|
||||||
`Post target surface: ${params.announceChannel}.`,
|
`Post target provider: ${params.announceChannel}.`,
|
||||||
`Original task: ${params.task}`,
|
`Original task: ${params.task}`,
|
||||||
params.subagentReply
|
params.subagentReply
|
||||||
? `Sub-agent result: ${params.subagentReply}`
|
? `Sub-agent result: ${params.subagentReply}`
|
||||||
: "Sub-agent result: (not available).",
|
: "Sub-agent result: (not available).",
|
||||||
'Reply exactly "ANNOUNCE_SKIP" to stay silent.',
|
'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);
|
].filter(Boolean);
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
@ -76,7 +81,7 @@ async function runSubagentAnnounceFlow(params: {
|
|||||||
childSessionKey: string;
|
childSessionKey: string;
|
||||||
childRunId: string;
|
childRunId: string;
|
||||||
requesterSessionKey: string;
|
requesterSessionKey: string;
|
||||||
requesterSurface?: string;
|
requesterProvider?: string;
|
||||||
requesterDisplayKey: string;
|
requesterDisplayKey: string;
|
||||||
task: string;
|
task: string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
@ -109,8 +114,8 @@ async function runSubagentAnnounceFlow(params: {
|
|||||||
|
|
||||||
const announcePrompt = buildSubagentAnnouncePrompt({
|
const announcePrompt = buildSubagentAnnouncePrompt({
|
||||||
requesterSessionKey: params.requesterSessionKey,
|
requesterSessionKey: params.requesterSessionKey,
|
||||||
requesterSurface: params.requesterSurface,
|
requesterProvider: params.requesterProvider,
|
||||||
announceChannel: announceTarget.channel,
|
announceChannel: announceTarget.provider,
|
||||||
task: params.task,
|
task: params.task,
|
||||||
subagentReply: reply,
|
subagentReply: reply,
|
||||||
});
|
});
|
||||||
@ -135,7 +140,8 @@ async function runSubagentAnnounceFlow(params: {
|
|||||||
params: {
|
params: {
|
||||||
to: announceTarget.to,
|
to: announceTarget.to,
|
||||||
message: announceReply.trim(),
|
message: announceReply.trim(),
|
||||||
provider: announceTarget.channel,
|
provider: announceTarget.provider,
|
||||||
|
accountId: announceTarget.accountId,
|
||||||
idempotencyKey: crypto.randomUUID(),
|
idempotencyKey: crypto.randomUUID(),
|
||||||
},
|
},
|
||||||
timeoutMs: 10_000,
|
timeoutMs: 10_000,
|
||||||
@ -159,7 +165,7 @@ async function runSubagentAnnounceFlow(params: {
|
|||||||
|
|
||||||
export function createSessionsSpawnTool(opts?: {
|
export function createSessionsSpawnTool(opts?: {
|
||||||
agentSessionKey?: string;
|
agentSessionKey?: string;
|
||||||
agentSurface?: string;
|
agentProvider?: string;
|
||||||
sandboxed?: boolean;
|
sandboxed?: boolean;
|
||||||
}): AnyAgentTool {
|
}): AnyAgentTool {
|
||||||
return {
|
return {
|
||||||
@ -188,7 +194,7 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
const requesterSessionKey = opts?.agentSessionKey;
|
const requesterSessionKey = opts?.agentSessionKey;
|
||||||
if (
|
if (
|
||||||
typeof requesterSessionKey === "string" &&
|
typeof requesterSessionKey === "string" &&
|
||||||
requesterSessionKey.trim().toLowerCase().startsWith("subagent:")
|
isSubagentSessionKey(requesterSessionKey)
|
||||||
) {
|
) {
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
status: "forbidden",
|
status: "forbidden",
|
||||||
@ -208,7 +214,10 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
mainKey,
|
mainKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const childSessionKey = `subagent:${crypto.randomUUID()}`;
|
const requesterAgentId = normalizeAgentId(
|
||||||
|
parseAgentSessionKey(requesterInternalKey)?.agentId,
|
||||||
|
);
|
||||||
|
const childSessionKey = `agent:${requesterAgentId}:subagent:${crypto.randomUUID()}`;
|
||||||
if (opts?.sandboxed === true) {
|
if (opts?.sandboxed === true) {
|
||||||
try {
|
try {
|
||||||
await callGateway({
|
await callGateway({
|
||||||
@ -222,7 +231,7 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
}
|
}
|
||||||
const childSystemPrompt = buildSubagentSystemPrompt({
|
const childSystemPrompt = buildSubagentSystemPrompt({
|
||||||
requesterSessionKey,
|
requesterSessionKey,
|
||||||
requesterSurface: opts?.agentSurface,
|
requesterProvider: opts?.agentProvider,
|
||||||
childSessionKey,
|
childSessionKey,
|
||||||
label: label || undefined,
|
label: label || undefined,
|
||||||
});
|
});
|
||||||
@ -265,7 +274,7 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
childSessionKey,
|
childSessionKey,
|
||||||
childRunId,
|
childRunId,
|
||||||
requesterSessionKey: requesterInternalKey,
|
requesterSessionKey: requesterInternalKey,
|
||||||
requesterSurface: opts?.agentSurface,
|
requesterProvider: opts?.agentProvider,
|
||||||
requesterDisplayKey,
|
requesterDisplayKey,
|
||||||
task,
|
task,
|
||||||
timeoutMs: 30_000,
|
timeoutMs: 30_000,
|
||||||
@ -311,7 +320,7 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
childSessionKey,
|
childSessionKey,
|
||||||
childRunId,
|
childRunId,
|
||||||
requesterSessionKey: requesterInternalKey,
|
requesterSessionKey: requesterInternalKey,
|
||||||
requesterSurface: opts?.agentSurface,
|
requesterProvider: opts?.agentProvider,
|
||||||
requesterDisplayKey,
|
requesterDisplayKey,
|
||||||
task,
|
task,
|
||||||
timeoutMs: 30_000,
|
timeoutMs: 30_000,
|
||||||
@ -329,7 +338,7 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
childSessionKey,
|
childSessionKey,
|
||||||
childRunId,
|
childRunId,
|
||||||
requesterSessionKey: requesterInternalKey,
|
requesterSessionKey: requesterInternalKey,
|
||||||
requesterSurface: opts?.agentSurface,
|
requesterProvider: opts?.agentProvider,
|
||||||
requesterDisplayKey,
|
requesterDisplayKey,
|
||||||
task,
|
task,
|
||||||
timeoutMs: 30_000,
|
timeoutMs: 30_000,
|
||||||
@ -350,7 +359,7 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
childSessionKey,
|
childSessionKey,
|
||||||
childRunId,
|
childRunId,
|
||||||
requesterSessionKey: requesterInternalKey,
|
requesterSessionKey: requesterInternalKey,
|
||||||
requesterSurface: opts?.agentSurface,
|
requesterProvider: opts?.agentProvider,
|
||||||
requesterDisplayKey,
|
requesterDisplayKey,
|
||||||
task,
|
task,
|
||||||
timeoutMs: 30_000,
|
timeoutMs: 30_000,
|
||||||
|
|||||||
@ -47,7 +47,7 @@ describe("chunkText", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveTextChunkLimit", () => {
|
describe("resolveTextChunkLimit", () => {
|
||||||
it("uses per-surface defaults", () => {
|
it("uses per-provider defaults", () => {
|
||||||
expect(resolveTextChunkLimit(undefined, "whatsapp")).toBe(4000);
|
expect(resolveTextChunkLimit(undefined, "whatsapp")).toBe(4000);
|
||||||
expect(resolveTextChunkLimit(undefined, "telegram")).toBe(4000);
|
expect(resolveTextChunkLimit(undefined, "telegram")).toBe(4000);
|
||||||
expect(resolveTextChunkLimit(undefined, "slack")).toBe(4000);
|
expect(resolveTextChunkLimit(undefined, "slack")).toBe(4000);
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
|
||||||
export type TextChunkSurface =
|
export type TextChunkProvider =
|
||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
| "telegram"
|
| "telegram"
|
||||||
| "discord"
|
| "discord"
|
||||||
@ -13,7 +13,7 @@ export type TextChunkSurface =
|
|||||||
| "imessage"
|
| "imessage"
|
||||||
| "webchat";
|
| "webchat";
|
||||||
|
|
||||||
const DEFAULT_CHUNK_LIMIT_BY_SURFACE: Record<TextChunkSurface, number> = {
|
const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record<TextChunkProvider, number> = {
|
||||||
whatsapp: 4000,
|
whatsapp: 4000,
|
||||||
telegram: 4000,
|
telegram: 4000,
|
||||||
discord: 2000,
|
discord: 2000,
|
||||||
@ -25,22 +25,22 @@ const DEFAULT_CHUNK_LIMIT_BY_SURFACE: Record<TextChunkSurface, number> = {
|
|||||||
|
|
||||||
export function resolveTextChunkLimit(
|
export function resolveTextChunkLimit(
|
||||||
cfg: ClawdbotConfig | undefined,
|
cfg: ClawdbotConfig | undefined,
|
||||||
surface?: TextChunkSurface,
|
provider?: TextChunkProvider,
|
||||||
): number {
|
): number {
|
||||||
const surfaceOverride = (() => {
|
const providerOverride = (() => {
|
||||||
if (!surface) return undefined;
|
if (!provider) return undefined;
|
||||||
if (surface === "whatsapp") return cfg?.whatsapp?.textChunkLimit;
|
if (provider === "whatsapp") return cfg?.whatsapp?.textChunkLimit;
|
||||||
if (surface === "telegram") return cfg?.telegram?.textChunkLimit;
|
if (provider === "telegram") return cfg?.telegram?.textChunkLimit;
|
||||||
if (surface === "discord") return cfg?.discord?.textChunkLimit;
|
if (provider === "discord") return cfg?.discord?.textChunkLimit;
|
||||||
if (surface === "slack") return cfg?.slack?.textChunkLimit;
|
if (provider === "slack") return cfg?.slack?.textChunkLimit;
|
||||||
if (surface === "signal") return cfg?.signal?.textChunkLimit;
|
if (provider === "signal") return cfg?.signal?.textChunkLimit;
|
||||||
if (surface === "imessage") return cfg?.imessage?.textChunkLimit;
|
if (provider === "imessage") return cfg?.imessage?.textChunkLimit;
|
||||||
return undefined;
|
return undefined;
|
||||||
})();
|
})();
|
||||||
if (typeof surfaceOverride === "number" && surfaceOverride > 0) {
|
if (typeof providerOverride === "number" && providerOverride > 0) {
|
||||||
return surfaceOverride;
|
return providerOverride;
|
||||||
}
|
}
|
||||||
if (surface) return DEFAULT_CHUNK_LIMIT_BY_SURFACE[surface];
|
if (provider) return DEFAULT_CHUNK_LIMIT_BY_PROVIDER[provider];
|
||||||
return 4000;
|
return 4000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { normalizeE164 } from "../utils.js";
|
|||||||
import type { MsgContext } from "./templating.js";
|
import type { MsgContext } from "./templating.js";
|
||||||
|
|
||||||
export type CommandAuthorization = {
|
export type CommandAuthorization = {
|
||||||
isWhatsAppSurface: boolean;
|
isWhatsAppProvider: boolean;
|
||||||
ownerList: string[];
|
ownerList: string[];
|
||||||
senderE164?: string;
|
senderE164?: string;
|
||||||
isAuthorizedSender: boolean;
|
isAuthorizedSender: boolean;
|
||||||
@ -17,7 +17,7 @@ export function resolveCommandAuthorization(params: {
|
|||||||
commandAuthorized: boolean;
|
commandAuthorized: boolean;
|
||||||
}): CommandAuthorization {
|
}): CommandAuthorization {
|
||||||
const { ctx, cfg, commandAuthorized } = params;
|
const { ctx, cfg, commandAuthorized } = params;
|
||||||
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
const provider = (ctx.Provider ?? "").trim().toLowerCase();
|
||||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||||
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||||
const hasWhatsappPrefix =
|
const hasWhatsappPrefix =
|
||||||
@ -26,30 +26,30 @@ export function resolveCommandAuthorization(params: {
|
|||||||
const looksLikeE164 = (value: string) =>
|
const looksLikeE164 = (value: string) =>
|
||||||
Boolean(value && /^\+?\d{3,}$/.test(value.replace(/[^\d+]/g, "")));
|
Boolean(value && /^\+?\d{3,}$/.test(value.replace(/[^\d+]/g, "")));
|
||||||
const inferWhatsApp =
|
const inferWhatsApp =
|
||||||
!surface &&
|
!provider &&
|
||||||
Boolean(cfg.whatsapp?.allowFrom?.length) &&
|
Boolean(cfg.whatsapp?.allowFrom?.length) &&
|
||||||
(looksLikeE164(from) || looksLikeE164(to));
|
(looksLikeE164(from) || looksLikeE164(to));
|
||||||
const isWhatsAppSurface =
|
const isWhatsAppProvider =
|
||||||
surface === "whatsapp" || hasWhatsappPrefix || inferWhatsApp;
|
provider === "whatsapp" || hasWhatsappPrefix || inferWhatsApp;
|
||||||
|
|
||||||
const configuredAllowFrom = isWhatsAppSurface
|
const configuredAllowFrom = isWhatsAppProvider
|
||||||
? cfg.whatsapp?.allowFrom
|
? cfg.whatsapp?.allowFrom
|
||||||
: undefined;
|
: undefined;
|
||||||
const allowFromList =
|
const allowFromList =
|
||||||
configuredAllowFrom?.filter((entry) => entry?.trim()) ?? [];
|
configuredAllowFrom?.filter((entry) => entry?.trim()) ?? [];
|
||||||
const allowAll =
|
const allowAll =
|
||||||
!isWhatsAppSurface ||
|
!isWhatsAppProvider ||
|
||||||
allowFromList.length === 0 ||
|
allowFromList.length === 0 ||
|
||||||
allowFromList.some((entry) => entry.trim() === "*");
|
allowFromList.some((entry) => entry.trim() === "*");
|
||||||
|
|
||||||
const senderE164 = normalizeE164(
|
const senderE164 = normalizeE164(
|
||||||
ctx.SenderE164 ?? (isWhatsAppSurface ? from : ""),
|
ctx.SenderE164 ?? (isWhatsAppProvider ? from : ""),
|
||||||
);
|
);
|
||||||
const ownerCandidates =
|
const ownerCandidates =
|
||||||
isWhatsAppSurface && !allowAll
|
isWhatsAppProvider && !allowAll
|
||||||
? allowFromList.filter((entry) => entry !== "*")
|
? allowFromList.filter((entry) => entry !== "*")
|
||||||
: [];
|
: [];
|
||||||
if (isWhatsAppSurface && !allowAll && ownerCandidates.length === 0 && to) {
|
if (isWhatsAppProvider && !allowAll && ownerCandidates.length === 0 && to) {
|
||||||
ownerCandidates.push(to);
|
ownerCandidates.push(to);
|
||||||
}
|
}
|
||||||
const ownerList = ownerCandidates
|
const ownerList = ownerCandidates
|
||||||
@ -57,14 +57,14 @@ export function resolveCommandAuthorization(params: {
|
|||||||
.filter((entry): entry is string => Boolean(entry));
|
.filter((entry): entry is string => Boolean(entry));
|
||||||
|
|
||||||
const isOwner =
|
const isOwner =
|
||||||
!isWhatsAppSurface ||
|
!isWhatsAppProvider ||
|
||||||
allowAll ||
|
allowAll ||
|
||||||
ownerList.length === 0 ||
|
ownerList.length === 0 ||
|
||||||
(senderE164 ? ownerList.includes(senderE164) : false);
|
(senderE164 ? ownerList.includes(senderE164) : false);
|
||||||
const isAuthorizedSender = commandAuthorized && isOwner;
|
const isAuthorizedSender = commandAuthorized && isOwner;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isWhatsAppSurface,
|
isWhatsAppProvider,
|
||||||
ownerList,
|
ownerList,
|
||||||
senderE164: senderE164 || undefined,
|
senderE164: senderE164 || undefined,
|
||||||
isAuthorizedSender,
|
isAuthorizedSender,
|
||||||
|
|||||||
@ -3,13 +3,13 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { formatAgentEnvelope } from "./envelope.js";
|
import { formatAgentEnvelope } from "./envelope.js";
|
||||||
|
|
||||||
describe("formatAgentEnvelope", () => {
|
describe("formatAgentEnvelope", () => {
|
||||||
it("includes surface, from, ip, host, and timestamp", () => {
|
it("includes provider, from, ip, host, and timestamp", () => {
|
||||||
const originalTz = process.env.TZ;
|
const originalTz = process.env.TZ;
|
||||||
process.env.TZ = "UTC";
|
process.env.TZ = "UTC";
|
||||||
|
|
||||||
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z
|
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z
|
||||||
const body = formatAgentEnvelope({
|
const body = formatAgentEnvelope({
|
||||||
surface: "WebChat",
|
provider: "WebChat",
|
||||||
from: "user1",
|
from: "user1",
|
||||||
host: "mac-mini",
|
host: "mac-mini",
|
||||||
ip: "10.0.0.5",
|
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 ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z
|
||||||
const body = formatAgentEnvelope({
|
const body = formatAgentEnvelope({
|
||||||
surface: "WebChat",
|
provider: "WebChat",
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
body: "hello",
|
body: "hello",
|
||||||
});
|
});
|
||||||
@ -41,7 +41,7 @@ describe("formatAgentEnvelope", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles missing optional fields", () => {
|
it("handles missing optional fields", () => {
|
||||||
const body = formatAgentEnvelope({ surface: "Telegram", body: "hi" });
|
const body = formatAgentEnvelope({ provider: "Telegram", body: "hi" });
|
||||||
expect(body).toBe("[Telegram] hi");
|
expect(body).toBe("[Telegram] hi");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
export type AgentEnvelopeParams = {
|
export type AgentEnvelopeParams = {
|
||||||
surface: string;
|
provider: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
timestamp?: number | Date;
|
timestamp?: number | Date;
|
||||||
host?: string;
|
host?: string;
|
||||||
@ -24,8 +24,8 @@ function formatTimestamp(ts?: number | Date): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
||||||
const surface = params.surface?.trim() || "Surface";
|
const provider = params.provider?.trim() || "Provider";
|
||||||
const parts: string[] = [surface];
|
const parts: string[] = [provider];
|
||||||
if (params.from?.trim()) parts.push(params.from.trim());
|
if (params.from?.trim()) parts.push(params.from.trim());
|
||||||
if (params.host?.trim()) parts.push(params.host.trim());
|
if (params.host?.trim()) parts.push(params.host.trim());
|
||||||
if (params.ip?.trim()) parts.push(params.ip.trim());
|
if (params.ip?.trim()) parts.push(params.ip.trim());
|
||||||
|
|||||||
@ -78,7 +78,7 @@ describe("block streaming", () => {
|
|||||||
From: "+1004",
|
From: "+1004",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
MessageSid: "msg-123",
|
MessageSid: "msg-123",
|
||||||
Surface: "discord",
|
Provider: "discord",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onReplyStart,
|
onReplyStart,
|
||||||
@ -124,7 +124,7 @@ describe("block streaming", () => {
|
|||||||
From: "+1004",
|
From: "+1004",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
MessageSid: "msg-124",
|
MessageSid: "msg-124",
|
||||||
Surface: "discord",
|
Provider: "discord",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onBlockReply,
|
onBlockReply,
|
||||||
|
|||||||
@ -321,7 +321,7 @@ describe("directive parsing", () => {
|
|||||||
Body: "/elevated maybe",
|
Body: "/elevated maybe",
|
||||||
From: "+1222",
|
From: "+1222",
|
||||||
To: "+1222",
|
To: "+1222",
|
||||||
Surface: "whatsapp",
|
Provider: "whatsapp",
|
||||||
SenderE164: "+1222",
|
SenderE164: "+1222",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
@ -709,7 +709,7 @@ describe("directive parsing", () => {
|
|||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Model set to openai/gpt-4.1-mini");
|
expect(text).toContain("Model set to openai/gpt-4.1-mini");
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
const entry = store.main;
|
const entry = store["agent:main:main"];
|
||||||
expect(entry.modelOverride).toBe("gpt-4.1-mini");
|
expect(entry.modelOverride).toBe("gpt-4.1-mini");
|
||||||
expect(entry.providerOverride).toBe("openai");
|
expect(entry.providerOverride).toBe("openai");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
@ -741,7 +741,7 @@ describe("directive parsing", () => {
|
|||||||
expect(text).toContain("Model set to Opus");
|
expect(text).toContain("Model set to Opus");
|
||||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
const entry = store.main;
|
const entry = store["agent:main:main"];
|
||||||
expect(entry.modelOverride).toBe("claude-opus-4-5");
|
expect(entry.modelOverride).toBe("claude-opus-4-5");
|
||||||
expect(entry.providerOverride).toBe("anthropic");
|
expect(entry.providerOverride).toBe("anthropic");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
@ -791,7 +791,7 @@ describe("directive parsing", () => {
|
|||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Auth profile set to anthropic:work");
|
expect(text).toContain("Auth profile set to anthropic:work");
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
const entry = store.main;
|
const entry = store["agent:main:main"];
|
||||||
expect(entry.authProfileOverride).toBe("anthropic:work");
|
expect(entry.authProfileOverride).toBe("anthropic:work");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@ -932,7 +932,7 @@ describe("directive parsing", () => {
|
|||||||
Body: "hello",
|
Body: "hello",
|
||||||
From: "+1004",
|
From: "+1004",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
Surface: "whatsapp",
|
Provider: "whatsapp",
|
||||||
SenderE164: "+1004",
|
SenderE164: "+1004",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
|
|||||||
@ -82,7 +82,7 @@ describe("getReplyFromConfig typing (heartbeat)", () => {
|
|||||||
const onReplyStart = vi.fn();
|
const onReplyStart = vi.fn();
|
||||||
|
|
||||||
await getReplyFromConfig(
|
await getReplyFromConfig(
|
||||||
{ Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" },
|
{ Body: "hi", From: "+1000", To: "+2000", Provider: "whatsapp" },
|
||||||
{ onReplyStart, isHeartbeat: false },
|
{ onReplyStart, isHeartbeat: false },
|
||||||
makeCfg(home),
|
makeCfg(home),
|
||||||
);
|
);
|
||||||
@ -100,7 +100,7 @@ describe("getReplyFromConfig typing (heartbeat)", () => {
|
|||||||
const onReplyStart = vi.fn();
|
const onReplyStart = vi.fn();
|
||||||
|
|
||||||
await getReplyFromConfig(
|
await getReplyFromConfig(
|
||||||
{ Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" },
|
{ Body: "hi", From: "+1000", To: "+2000", Provider: "whatsapp" },
|
||||||
{ onReplyStart, isHeartbeat: true },
|
{ onReplyStart, isHeartbeat: true },
|
||||||
makeCfg(home),
|
makeCfg(home),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import { loadSessionStore, resolveSessionKey } from "../config/sessions.js";
|
|||||||
import { getReplyFromConfig } from "./reply.js";
|
import { getReplyFromConfig } from "./reply.js";
|
||||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||||
|
|
||||||
|
const MAIN_SESSION_KEY = "agent:main:main";
|
||||||
|
|
||||||
const webMocks = vi.hoisted(() => ({
|
const webMocks = vi.hoisted(() => ({
|
||||||
webAuthExists: vi.fn().mockResolvedValue(true),
|
webAuthExists: vi.fn().mockResolvedValue(true),
|
||||||
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
|
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
|
||||||
@ -166,7 +168,7 @@ describe("trigger handling", () => {
|
|||||||
Body: "/send off",
|
Body: "/send off",
|
||||||
From: "+1000",
|
From: "+1000",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
Surface: "whatsapp",
|
Provider: "whatsapp",
|
||||||
SenderE164: "+1000",
|
SenderE164: "+1000",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
@ -180,7 +182,7 @@ describe("trigger handling", () => {
|
|||||||
string,
|
string,
|
||||||
{ sendPolicy?: 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",
|
Body: "/elevated on",
|
||||||
From: "+1000",
|
From: "+1000",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
Surface: "whatsapp",
|
Provider: "whatsapp",
|
||||||
SenderE164: "+1000",
|
SenderE164: "+1000",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
@ -219,7 +221,7 @@ describe("trigger handling", () => {
|
|||||||
string,
|
string,
|
||||||
{ elevatedLevel?: 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",
|
Body: "/elevated on",
|
||||||
From: "+1000",
|
From: "+1000",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
Surface: "whatsapp",
|
Provider: "whatsapp",
|
||||||
SenderE164: "+1000",
|
SenderE164: "+1000",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
@ -259,7 +261,7 @@ describe("trigger handling", () => {
|
|||||||
string,
|
string,
|
||||||
{ elevatedLevel?: 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",
|
Body: "please /elevated on now",
|
||||||
From: "+2000",
|
From: "+2000",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
Surface: "whatsapp",
|
Provider: "whatsapp",
|
||||||
SenderE164: "+2000",
|
SenderE164: "+2000",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
@ -316,7 +318,7 @@ describe("trigger handling", () => {
|
|||||||
Body: "/elevated on",
|
Body: "/elevated on",
|
||||||
From: "discord:123",
|
From: "discord:123",
|
||||||
To: "user:123",
|
To: "user:123",
|
||||||
Surface: "discord",
|
Provider: "discord",
|
||||||
SenderName: "Peter Steinberger",
|
SenderName: "Peter Steinberger",
|
||||||
SenderUsername: "steipete",
|
SenderUsername: "steipete",
|
||||||
SenderTag: "steipete",
|
SenderTag: "steipete",
|
||||||
@ -332,7 +334,7 @@ describe("trigger handling", () => {
|
|||||||
string,
|
string,
|
||||||
{ elevatedLevel?: 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",
|
Body: "/elevated on",
|
||||||
From: "discord:123",
|
From: "discord:123",
|
||||||
To: "user:123",
|
To: "user:123",
|
||||||
Surface: "discord",
|
Provider: "discord",
|
||||||
SenderName: "steipete",
|
SenderName: "steipete",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
@ -510,7 +512,7 @@ describe("trigger handling", () => {
|
|||||||
From: "123@g.us",
|
From: "123@g.us",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
ChatType: "group",
|
ChatType: "group",
|
||||||
Surface: "whatsapp",
|
Provider: "whatsapp",
|
||||||
SenderE164: "+2000",
|
SenderE164: "+2000",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
@ -521,7 +523,9 @@ describe("trigger handling", () => {
|
|||||||
const store = JSON.parse(
|
const store = JSON.parse(
|
||||||
await fs.readFile(cfg.session.store, "utf-8"),
|
await fs.readFile(cfg.session.store, "utf-8"),
|
||||||
) as Record<string, { groupActivation?: string }>;
|
) as Record<string, { groupActivation?: string }>;
|
||||||
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();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -535,7 +539,7 @@ describe("trigger handling", () => {
|
|||||||
From: "123@g.us",
|
From: "123@g.us",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
ChatType: "group",
|
ChatType: "group",
|
||||||
Surface: "whatsapp",
|
Provider: "whatsapp",
|
||||||
SenderE164: "+999",
|
SenderE164: "+999",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
@ -563,7 +567,7 @@ describe("trigger handling", () => {
|
|||||||
From: "123@g.us",
|
From: "123@g.us",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
ChatType: "group",
|
ChatType: "group",
|
||||||
Surface: "whatsapp",
|
Provider: "whatsapp",
|
||||||
SenderE164: "+2000",
|
SenderE164: "+2000",
|
||||||
GroupSubject: "Test Group",
|
GroupSubject: "Test Group",
|
||||||
GroupMembers: "Alice (+1), Bob (+2)",
|
GroupMembers: "Alice (+1), Bob (+2)",
|
||||||
@ -879,7 +883,7 @@ describe("trigger handling", () => {
|
|||||||
From: "group:whatsapp:demo",
|
From: "group:whatsapp:demo",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
ChatType: "group" as const,
|
ChatType: "group" as const,
|
||||||
Surface: "whatsapp" as const,
|
Provider: "whatsapp" as const,
|
||||||
MediaPath: mediaPath,
|
MediaPath: mediaPath,
|
||||||
MediaType: "image/jpeg",
|
MediaType: "image/jpeg",
|
||||||
MediaUrl: mediaPath,
|
MediaUrl: mediaPath,
|
||||||
@ -942,7 +946,7 @@ describe("group intro prompts", () => {
|
|||||||
ChatType: "group",
|
ChatType: "group",
|
||||||
GroupSubject: "Release Squad",
|
GroupSubject: "Release Squad",
|
||||||
GroupMembers: "Alice, Bob",
|
GroupMembers: "Alice, Bob",
|
||||||
Surface: "discord",
|
Provider: "discord",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
makeCfg(home),
|
makeCfg(home),
|
||||||
@ -975,7 +979,7 @@ describe("group intro prompts", () => {
|
|||||||
To: "+1999",
|
To: "+1999",
|
||||||
ChatType: "group",
|
ChatType: "group",
|
||||||
GroupSubject: "Ops",
|
GroupSubject: "Ops",
|
||||||
Surface: "whatsapp",
|
Provider: "whatsapp",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
makeCfg(home),
|
makeCfg(home),
|
||||||
@ -1008,7 +1012,7 @@ describe("group intro prompts", () => {
|
|||||||
To: "+1777",
|
To: "+1777",
|
||||||
ChatType: "group",
|
ChatType: "group",
|
||||||
GroupSubject: "Dev Chat",
|
GroupSubject: "Dev Chat",
|
||||||
Surface: "telegram",
|
Provider: "telegram",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
makeCfg(home),
|
makeCfg(home),
|
||||||
|
|||||||
@ -2,7 +2,11 @@ import crypto from "node:crypto";
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
resolveAgentDir,
|
||||||
|
resolveAgentIdFromSessionKey,
|
||||||
|
resolveAgentWorkspaceDir,
|
||||||
|
} from "../agents/agent-scope.js";
|
||||||
import { resolveModelRefFromString } from "../agents/model-selection.js";
|
import { resolveModelRefFromString } from "../agents/model-selection.js";
|
||||||
import {
|
import {
|
||||||
abortEmbeddedPiRun,
|
abortEmbeddedPiRun,
|
||||||
@ -108,10 +112,10 @@ function stripSenderPrefix(value?: string) {
|
|||||||
|
|
||||||
function resolveElevatedAllowList(
|
function resolveElevatedAllowList(
|
||||||
allowFrom: AgentElevatedAllowFromConfig | undefined,
|
allowFrom: AgentElevatedAllowFromConfig | undefined,
|
||||||
surface: string,
|
provider: string,
|
||||||
discordFallback?: Array<string | number>,
|
discordFallback?: Array<string | number>,
|
||||||
): Array<string | number> | undefined {
|
): Array<string | number> | undefined {
|
||||||
switch (surface) {
|
switch (provider) {
|
||||||
case "whatsapp":
|
case "whatsapp":
|
||||||
return allowFrom?.whatsapp;
|
return allowFrom?.whatsapp;
|
||||||
case "telegram":
|
case "telegram":
|
||||||
@ -135,14 +139,14 @@ function resolveElevatedAllowList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isApprovedElevatedSender(params: {
|
function isApprovedElevatedSender(params: {
|
||||||
surface: string;
|
provider: string;
|
||||||
ctx: MsgContext;
|
ctx: MsgContext;
|
||||||
allowFrom?: AgentElevatedAllowFromConfig;
|
allowFrom?: AgentElevatedAllowFromConfig;
|
||||||
discordFallback?: Array<string | number>;
|
discordFallback?: Array<string | number>;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const rawAllow = resolveElevatedAllowList(
|
const rawAllow = resolveElevatedAllowList(
|
||||||
params.allowFrom,
|
params.allowFrom,
|
||||||
params.surface,
|
params.provider,
|
||||||
params.discordFallback,
|
params.discordFallback,
|
||||||
);
|
);
|
||||||
if (!rawAllow || rawAllow.length === 0) return false;
|
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({
|
const workspace = await ensureAgentWorkspace({
|
||||||
dir: workspaceDirRaw,
|
dir: workspaceDirRaw,
|
||||||
ensureBootstrapFiles: !cfg.agent?.skipBootstrap,
|
ensureBootstrapFiles: !cfg.agent?.skipBootstrap,
|
||||||
});
|
});
|
||||||
const workspaceDir = workspace.dir;
|
const workspaceDir = workspace.dir;
|
||||||
|
const agentDir = resolveAgentDir(cfg, agentId);
|
||||||
const timeoutMs = resolveAgentTimeoutMs({ cfg });
|
const timeoutMs = resolveAgentTimeoutMs({ cfg });
|
||||||
const configuredTypingSeconds =
|
const configuredTypingSeconds =
|
||||||
agentCfg?.typingIntervalSeconds ?? sessionCfg?.typingIntervalSeconds;
|
agentCfg?.typingIntervalSeconds ?? sessionCfg?.typingIntervalSeconds;
|
||||||
@ -289,20 +296,20 @@ export async function getReplyFromConfig(
|
|||||||
sessionCtx.Body = parsedDirectives.cleaned;
|
sessionCtx.Body = parsedDirectives.cleaned;
|
||||||
sessionCtx.BodyStripped = parsedDirectives.cleaned;
|
sessionCtx.BodyStripped = parsedDirectives.cleaned;
|
||||||
|
|
||||||
const surfaceKey =
|
const messageProviderKey =
|
||||||
sessionCtx.Surface?.trim().toLowerCase() ??
|
sessionCtx.Provider?.trim().toLowerCase() ??
|
||||||
ctx.Surface?.trim().toLowerCase() ??
|
ctx.Provider?.trim().toLowerCase() ??
|
||||||
"";
|
"";
|
||||||
const elevatedConfig = agentCfg?.elevated;
|
const elevatedConfig = agentCfg?.elevated;
|
||||||
const discordElevatedFallback =
|
const discordElevatedFallback =
|
||||||
surfaceKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
|
messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
|
||||||
const elevatedEnabled = elevatedConfig?.enabled !== false;
|
const elevatedEnabled = elevatedConfig?.enabled !== false;
|
||||||
const elevatedAllowed =
|
const elevatedAllowed =
|
||||||
elevatedEnabled &&
|
elevatedEnabled &&
|
||||||
Boolean(
|
Boolean(
|
||||||
surfaceKey &&
|
messageProviderKey &&
|
||||||
isApprovedElevatedSender({
|
isApprovedElevatedSender({
|
||||||
surface: surfaceKey,
|
provider: messageProviderKey,
|
||||||
ctx,
|
ctx,
|
||||||
allowFrom: elevatedConfig?.allowFrom,
|
allowFrom: elevatedConfig?.allowFrom,
|
||||||
discordFallback: discordElevatedFallback,
|
discordFallback: discordElevatedFallback,
|
||||||
@ -345,7 +352,7 @@ export async function getReplyFromConfig(
|
|||||||
: "text_end";
|
: "text_end";
|
||||||
const blockStreamingEnabled = resolvedBlockStreaming === "on";
|
const blockStreamingEnabled = resolvedBlockStreaming === "on";
|
||||||
const blockReplyChunking = blockStreamingEnabled
|
const blockReplyChunking = blockStreamingEnabled
|
||||||
? resolveBlockStreamingChunking(cfg, sessionCtx.Surface)
|
? resolveBlockStreamingChunking(cfg, sessionCtx.Provider)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const modelState = await createModelSelectionState({
|
const modelState = await createModelSelectionState({
|
||||||
@ -463,7 +470,7 @@ export async function getReplyFromConfig(
|
|||||||
});
|
});
|
||||||
const isEmptyConfig = Object.keys(cfg).length === 0;
|
const isEmptyConfig = Object.keys(cfg).length === 0;
|
||||||
if (
|
if (
|
||||||
command.isWhatsAppSurface &&
|
command.isWhatsAppProvider &&
|
||||||
isEmptyConfig &&
|
isEmptyConfig &&
|
||||||
command.from &&
|
command.from &&
|
||||||
command.to &&
|
command.to &&
|
||||||
@ -638,7 +645,7 @@ export async function getReplyFromConfig(
|
|||||||
: queueBodyBase;
|
: queueBodyBase;
|
||||||
const resolvedQueue = resolveQueueSettings({
|
const resolvedQueue = resolveQueueSettings({
|
||||||
cfg,
|
cfg,
|
||||||
surface: sessionCtx.Surface,
|
provider: sessionCtx.Provider,
|
||||||
sessionEntry,
|
sessionEntry,
|
||||||
inlineMode: perMessageQueueMode,
|
inlineMode: perMessageQueueMode,
|
||||||
inlineOptions: perMessageQueueOptions,
|
inlineOptions: perMessageQueueOptions,
|
||||||
@ -669,9 +676,11 @@ export async function getReplyFromConfig(
|
|||||||
summaryLine: baseBodyTrimmedRaw,
|
summaryLine: baseBodyTrimmedRaw,
|
||||||
enqueuedAt: Date.now(),
|
enqueuedAt: Date.now(),
|
||||||
run: {
|
run: {
|
||||||
|
agentId,
|
||||||
|
agentDir,
|
||||||
sessionId: sessionIdFinal,
|
sessionId: sessionIdFinal,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
surface: sessionCtx.Surface?.trim().toLowerCase() || undefined,
|
messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
|||||||
@ -71,7 +71,7 @@ function createMinimalRun(params?: {
|
|||||||
const typing = createTyping();
|
const typing = createTyping();
|
||||||
const opts = params?.opts;
|
const opts = params?.opts;
|
||||||
const sessionCtx = {
|
const sessionCtx = {
|
||||||
Surface: "whatsapp",
|
Provider: "whatsapp",
|
||||||
MessageSid: "msg",
|
MessageSid: "msg",
|
||||||
} as unknown as TemplateContext;
|
} as unknown as TemplateContext;
|
||||||
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
|
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
|
||||||
@ -83,7 +83,7 @@ function createMinimalRun(params?: {
|
|||||||
run: {
|
run: {
|
||||||
sessionId: "session",
|
sessionId: "session",
|
||||||
sessionKey,
|
sessionKey,
|
||||||
surface: "whatsapp",
|
messageProvider: "whatsapp",
|
||||||
sessionFile: "/tmp/session.jsonl",
|
sessionFile: "/tmp/session.jsonl",
|
||||||
workspaceDir: "/tmp",
|
workspaceDir: "/tmp",
|
||||||
config: {},
|
config: {},
|
||||||
|
|||||||
@ -186,9 +186,11 @@ export async function runReplyAgent(params: {
|
|||||||
runEmbeddedPiAgent({
|
runEmbeddedPiAgent({
|
||||||
sessionId: followupRun.run.sessionId,
|
sessionId: followupRun.run.sessionId,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
surface: sessionCtx.Surface?.trim().toLowerCase() || undefined,
|
messageProvider:
|
||||||
|
sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||||
sessionFile: followupRun.run.sessionFile,
|
sessionFile: followupRun.run.sessionFile,
|
||||||
workspaceDir: followupRun.run.workspaceDir,
|
workspaceDir: followupRun.run.workspaceDir,
|
||||||
|
agentDir: followupRun.run.agentDir,
|
||||||
config: followupRun.run.config,
|
config: followupRun.run.config,
|
||||||
skillsSnapshot: followupRun.run.skillsSnapshot,
|
skillsSnapshot: followupRun.run.skillsSnapshot,
|
||||||
prompt: commandBody,
|
prompt: commandBody,
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
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_MIN = 800;
|
||||||
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
||||||
|
|
||||||
const BLOCK_CHUNK_SURFACES = new Set<TextChunkSurface>([
|
const BLOCK_CHUNK_PROVIDERS = new Set<TextChunkProvider>([
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
"telegram",
|
"telegram",
|
||||||
"discord",
|
"discord",
|
||||||
@ -14,24 +14,26 @@ const BLOCK_CHUNK_SURFACES = new Set<TextChunkSurface>([
|
|||||||
"webchat",
|
"webchat",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function normalizeChunkSurface(surface?: string): TextChunkSurface | undefined {
|
function normalizeChunkProvider(
|
||||||
if (!surface) return undefined;
|
provider?: string,
|
||||||
const cleaned = surface.trim().toLowerCase();
|
): TextChunkProvider | undefined {
|
||||||
return BLOCK_CHUNK_SURFACES.has(cleaned as TextChunkSurface)
|
if (!provider) return undefined;
|
||||||
? (cleaned as TextChunkSurface)
|
const cleaned = provider.trim().toLowerCase();
|
||||||
|
return BLOCK_CHUNK_PROVIDERS.has(cleaned as TextChunkProvider)
|
||||||
|
? (cleaned as TextChunkProvider)
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveBlockStreamingChunking(
|
export function resolveBlockStreamingChunking(
|
||||||
cfg: ClawdbotConfig | undefined,
|
cfg: ClawdbotConfig | undefined,
|
||||||
surface?: string,
|
provider?: string,
|
||||||
): {
|
): {
|
||||||
minChars: number;
|
minChars: number;
|
||||||
maxChars: number;
|
maxChars: number;
|
||||||
breakPreference: "paragraph" | "newline" | "sentence";
|
breakPreference: "paragraph" | "newline" | "sentence";
|
||||||
} {
|
} {
|
||||||
const surfaceKey = normalizeChunkSurface(surface);
|
const providerKey = normalizeChunkProvider(provider);
|
||||||
const textLimit = resolveTextChunkLimit(cfg, surfaceKey);
|
const textLimit = resolveTextChunkLimit(cfg, providerKey);
|
||||||
const chunkCfg = cfg?.agent?.blockStreamingChunk;
|
const chunkCfg = cfg?.agent?.blockStreamingChunk;
|
||||||
const maxRequested = Math.max(
|
const maxRequested = Math.max(
|
||||||
1,
|
1,
|
||||||
|
|||||||
@ -47,8 +47,8 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
|||||||
import { incrementCompactionCount } from "./session-updates.js";
|
import { incrementCompactionCount } from "./session-updates.js";
|
||||||
|
|
||||||
export type CommandContext = {
|
export type CommandContext = {
|
||||||
surface: string;
|
provider: string;
|
||||||
isWhatsAppSurface: boolean;
|
isWhatsAppProvider: boolean;
|
||||||
ownerList: string[];
|
ownerList: string[];
|
||||||
isAuthorizedSender: boolean;
|
isAuthorizedSender: boolean;
|
||||||
senderE164?: string;
|
senderE164?: string;
|
||||||
@ -123,7 +123,7 @@ export function buildCommandContext(params: {
|
|||||||
cfg,
|
cfg,
|
||||||
commandAuthorized: params.commandAuthorized,
|
commandAuthorized: params.commandAuthorized,
|
||||||
});
|
});
|
||||||
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
const provider = (ctx.Provider ?? "").trim().toLowerCase();
|
||||||
const abortKey =
|
const abortKey =
|
||||||
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
|
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
|
||||||
const rawBodyNormalized = triggerBodyNormalized;
|
const rawBodyNormalized = triggerBodyNormalized;
|
||||||
@ -132,8 +132,8 @@ export function buildCommandContext(params: {
|
|||||||
: rawBodyNormalized;
|
: rawBodyNormalized;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
surface,
|
provider,
|
||||||
isWhatsAppSurface: auth.isWhatsAppSurface,
|
isWhatsAppProvider: auth.isWhatsAppProvider,
|
||||||
ownerList: auth.ownerList,
|
ownerList: auth.ownerList,
|
||||||
isAuthorizedSender: auth.isAuthorizedSender,
|
isAuthorizedSender: auth.isAuthorizedSender,
|
||||||
senderE164: auth.senderE164,
|
senderE164: auth.senderE164,
|
||||||
@ -220,14 +220,14 @@ export async function handleCommands(params: {
|
|||||||
? normalizeE164(command.senderE164)
|
? normalizeE164(command.senderE164)
|
||||||
: "";
|
: "";
|
||||||
const isActivationOwner =
|
const isActivationOwner =
|
||||||
!command.isWhatsAppSurface || activationOwnerList.length === 0
|
!command.isWhatsAppProvider || activationOwnerList.length === 0
|
||||||
? command.isAuthorizedSender
|
? command.isAuthorizedSender
|
||||||
: Boolean(activationSenderE164) &&
|
: Boolean(activationSenderE164) &&
|
||||||
activationOwnerList.includes(activationSenderE164);
|
activationOwnerList.includes(activationSenderE164);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!command.isAuthorizedSender ||
|
!command.isAuthorizedSender ||
|
||||||
(command.isWhatsAppSurface && !isActivationOwner)
|
(command.isWhatsAppProvider && !isActivationOwner)
|
||||||
) {
|
) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Ignoring /activation from unauthorized sender in group: ${command.senderE164 || "<unknown>"}`,
|
`Ignoring /activation from unauthorized sender in group: ${command.senderE164 || "<unknown>"}`,
|
||||||
@ -402,7 +402,7 @@ export async function handleCommands(params: {
|
|||||||
const result = await compactEmbeddedPiSession({
|
const result = await compactEmbeddedPiSession({
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
surface: command.surface,
|
messageProvider: command.provider,
|
||||||
sessionFile: resolveSessionTranscriptPath(sessionId),
|
sessionFile: resolveSessionTranscriptPath(sessionId),
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
@ -469,7 +469,7 @@ export async function handleCommands(params: {
|
|||||||
cfg,
|
cfg,
|
||||||
entry: sessionEntry,
|
entry: sessionEntry,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
surface: sessionEntry?.surface ?? command.surface,
|
provider: sessionEntry?.provider ?? command.provider,
|
||||||
chatType: sessionEntry?.chatType,
|
chatType: sessionEntry?.chatType,
|
||||||
});
|
});
|
||||||
if (sendPolicy === "deny") {
|
if (sendPolicy === "deny") {
|
||||||
|
|||||||
@ -90,7 +90,7 @@ describe("createFollowupRunner compaction", () => {
|
|||||||
run: {
|
run: {
|
||||||
sessionId: "session",
|
sessionId: "session",
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
surface: "whatsapp",
|
messageProvider: "whatsapp",
|
||||||
sessionFile: "/tmp/session.jsonl",
|
sessionFile: "/tmp/session.jsonl",
|
||||||
workspaceDir: "/tmp",
|
workspaceDir: "/tmp",
|
||||||
config: {},
|
config: {},
|
||||||
|
|||||||
@ -76,7 +76,7 @@ export function createFollowupRunner(params: {
|
|||||||
runEmbeddedPiAgent({
|
runEmbeddedPiAgent({
|
||||||
sessionId: queued.run.sessionId,
|
sessionId: queued.run.sessionId,
|
||||||
sessionKey: queued.run.sessionKey,
|
sessionKey: queued.run.sessionKey,
|
||||||
surface: queued.run.surface,
|
messageProvider: queued.run.messageProvider,
|
||||||
sessionFile: queued.run.sessionFile,
|
sessionFile: queued.run.sessionFile,
|
||||||
workspaceDir: queued.run.workspaceDir,
|
workspaceDir: queued.run.workspaceDir,
|
||||||
config: queued.run.config,
|
config: queued.run.config,
|
||||||
|
|||||||
@ -19,13 +19,13 @@ describe("resolveGroupRequireMention", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const ctx: TemplateContext = {
|
const ctx: TemplateContext = {
|
||||||
Surface: "discord",
|
Provider: "discord",
|
||||||
From: "group:123",
|
From: "group:123",
|
||||||
GroupRoom: "#general",
|
GroupRoom: "#general",
|
||||||
GroupSpace: "145",
|
GroupSpace: "145",
|
||||||
};
|
};
|
||||||
const groupResolution: GroupKeyResolution = {
|
const groupResolution: GroupKeyResolution = {
|
||||||
surface: "discord",
|
provider: "discord",
|
||||||
id: "123",
|
id: "123",
|
||||||
chatType: "group",
|
chatType: "group",
|
||||||
};
|
};
|
||||||
@ -44,12 +44,12 @@ describe("resolveGroupRequireMention", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const ctx: TemplateContext = {
|
const ctx: TemplateContext = {
|
||||||
Surface: "slack",
|
Provider: "slack",
|
||||||
From: "slack:channel:C123",
|
From: "slack:channel:C123",
|
||||||
GroupSubject: "#general",
|
GroupSubject: "#general",
|
||||||
};
|
};
|
||||||
const groupResolution: GroupKeyResolution = {
|
const groupResolution: GroupKeyResolution = {
|
||||||
surface: "slack",
|
provider: "slack",
|
||||||
id: "C123",
|
id: "C123",
|
||||||
chatType: "group",
|
chatType: "group",
|
||||||
};
|
};
|
||||||
|
|||||||
@ -50,22 +50,23 @@ export function resolveGroupRequireMention(params: {
|
|||||||
groupResolution?: GroupKeyResolution;
|
groupResolution?: GroupKeyResolution;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const { cfg, ctx, groupResolution } = params;
|
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 groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
|
||||||
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
|
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
|
||||||
const groupSpace = ctx.GroupSpace?.trim();
|
const groupSpace = ctx.GroupSpace?.trim();
|
||||||
if (
|
if (
|
||||||
surface === "telegram" ||
|
provider === "telegram" ||
|
||||||
surface === "whatsapp" ||
|
provider === "whatsapp" ||
|
||||||
surface === "imessage"
|
provider === "imessage"
|
||||||
) {
|
) {
|
||||||
return resolveProviderGroupRequireMention({
|
return resolveProviderGroupRequireMention({
|
||||||
cfg,
|
cfg,
|
||||||
surface,
|
provider,
|
||||||
groupId,
|
groupId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (surface === "discord") {
|
if (provider === "discord") {
|
||||||
const guildEntry = resolveDiscordGuildEntry(
|
const guildEntry = resolveDiscordGuildEntry(
|
||||||
cfg.discord?.guilds,
|
cfg.discord?.guilds,
|
||||||
groupSpace,
|
groupSpace,
|
||||||
@ -90,7 +91,7 @@ export function resolveGroupRequireMention(params: {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (surface === "slack") {
|
if (provider === "slack") {
|
||||||
const channels = cfg.slack?.channels ?? {};
|
const channels = cfg.slack?.channels ?? {};
|
||||||
const keys = Object.keys(channels);
|
const keys = Object.keys(channels);
|
||||||
if (keys.length === 0) return true;
|
if (keys.length === 0) return true;
|
||||||
@ -137,18 +138,18 @@ export function buildGroupIntro(params: {
|
|||||||
params.defaultActivation;
|
params.defaultActivation;
|
||||||
const subject = params.sessionCtx.GroupSubject?.trim();
|
const subject = params.sessionCtx.GroupSubject?.trim();
|
||||||
const members = params.sessionCtx.GroupMembers?.trim();
|
const members = params.sessionCtx.GroupMembers?.trim();
|
||||||
const surface = params.sessionCtx.Surface?.trim().toLowerCase();
|
const provider = params.sessionCtx.Provider?.trim().toLowerCase();
|
||||||
const surfaceLabel = (() => {
|
const providerLabel = (() => {
|
||||||
if (!surface) return "chat";
|
if (!provider) return "chat";
|
||||||
if (surface === "whatsapp") return "WhatsApp";
|
if (provider === "whatsapp") return "WhatsApp";
|
||||||
if (surface === "telegram") return "Telegram";
|
if (provider === "telegram") return "Telegram";
|
||||||
if (surface === "discord") return "Discord";
|
if (provider === "discord") return "Discord";
|
||||||
if (surface === "webchat") return "WebChat";
|
if (provider === "webchat") return "WebChat";
|
||||||
return `${surface.at(0)?.toUpperCase() ?? ""}${surface.slice(1)}`;
|
return `${provider.at(0)?.toUpperCase() ?? ""}${provider.slice(1)}`;
|
||||||
})();
|
})();
|
||||||
const subjectLine = subject
|
const subjectLine = subject
|
||||||
? `You are replying inside the ${surfaceLabel} group "${subject}".`
|
? `You are replying inside the ${providerLabel} group "${subject}".`
|
||||||
: `You are replying inside a ${surfaceLabel} group chat.`;
|
: `You are replying inside a ${providerLabel} group chat.`;
|
||||||
const membersLine = members ? `Group members: ${members}.` : undefined;
|
const membersLine = members ? `Group members: ${members}.` : undefined;
|
||||||
const activationLine =
|
const activationLine =
|
||||||
activation === "always"
|
activation === "always"
|
||||||
|
|||||||
@ -23,9 +23,11 @@ export type FollowupRun = {
|
|||||||
summaryLine?: string;
|
summaryLine?: string;
|
||||||
enqueuedAt: number;
|
enqueuedAt: number;
|
||||||
run: {
|
run: {
|
||||||
|
agentId: string;
|
||||||
|
agentDir: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
surface?: string;
|
messageProvider?: string;
|
||||||
sessionFile: string;
|
sessionFile: string;
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
config: ClawdbotConfig;
|
config: ClawdbotConfig;
|
||||||
@ -425,8 +427,8 @@ export function scheduleFollowupDrain(
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
function defaultQueueModeForSurface(surface?: string): QueueMode {
|
function defaultQueueModeForProvider(provider?: string): QueueMode {
|
||||||
const normalized = surface?.trim().toLowerCase();
|
const normalized = provider?.trim().toLowerCase();
|
||||||
if (normalized === "discord") return "collect";
|
if (normalized === "discord") return "collect";
|
||||||
if (normalized === "webchat") return "collect";
|
if (normalized === "webchat") return "collect";
|
||||||
if (normalized === "whatsapp") return "collect";
|
if (normalized === "whatsapp") return "collect";
|
||||||
@ -437,23 +439,23 @@ function defaultQueueModeForSurface(surface?: string): QueueMode {
|
|||||||
}
|
}
|
||||||
export function resolveQueueSettings(params: {
|
export function resolveQueueSettings(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
surface?: string;
|
provider?: string;
|
||||||
sessionEntry?: SessionEntry;
|
sessionEntry?: SessionEntry;
|
||||||
inlineMode?: QueueMode;
|
inlineMode?: QueueMode;
|
||||||
inlineOptions?: Partial<QueueSettings>;
|
inlineOptions?: Partial<QueueSettings>;
|
||||||
}): QueueSettings {
|
}): QueueSettings {
|
||||||
const surfaceKey = params.surface?.trim().toLowerCase();
|
const providerKey = params.provider?.trim().toLowerCase();
|
||||||
const queueCfg = params.cfg.routing?.queue;
|
const queueCfg = params.cfg.routing?.queue;
|
||||||
const surfaceModeRaw =
|
const providerModeRaw =
|
||||||
surfaceKey && queueCfg?.bySurface
|
providerKey && queueCfg?.byProvider
|
||||||
? (queueCfg.bySurface as Record<string, string | undefined>)[surfaceKey]
|
? (queueCfg.byProvider as Record<string, string | undefined>)[providerKey]
|
||||||
: undefined;
|
: undefined;
|
||||||
const resolvedMode =
|
const resolvedMode =
|
||||||
params.inlineMode ??
|
params.inlineMode ??
|
||||||
normalizeQueueMode(params.sessionEntry?.queueMode) ??
|
normalizeQueueMode(params.sessionEntry?.queueMode) ??
|
||||||
normalizeQueueMode(surfaceModeRaw) ??
|
normalizeQueueMode(providerModeRaw) ??
|
||||||
normalizeQueueMode(queueCfg?.mode) ??
|
normalizeQueueMode(queueCfg?.mode) ??
|
||||||
defaultQueueModeForSurface(surfaceKey);
|
defaultQueueModeForProvider(providerKey);
|
||||||
const debounceRaw =
|
const debounceRaw =
|
||||||
params.inlineOptions?.debounceMs ??
|
params.inlineOptions?.debounceMs ??
|
||||||
params.sessionEntry?.queueDebounceMs ??
|
params.sessionEntry?.queueDebounceMs ??
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
DEFAULT_RESET_TRIGGERS,
|
DEFAULT_RESET_TRIGGERS,
|
||||||
type GroupKeyResolution,
|
type GroupKeyResolution,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
resolveAgentIdFromSessionKey,
|
||||||
resolveGroupSessionKey,
|
resolveGroupSessionKey,
|
||||||
resolveSessionKey,
|
resolveSessionKey,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
@ -43,6 +44,7 @@ export async function initSessionState(params: {
|
|||||||
const { ctx, cfg, commandAuthorized } = params;
|
const { ctx, cfg, commandAuthorized } = params;
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
const mainKey = sessionCfg?.mainKey ?? "main";
|
const mainKey = sessionCfg?.mainKey ?? "main";
|
||||||
|
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
|
||||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||||
? sessionCfg.resetTriggers
|
? sessionCfg.resetTriggers
|
||||||
: DEFAULT_RESET_TRIGGERS;
|
: DEFAULT_RESET_TRIGGERS;
|
||||||
@ -51,12 +53,12 @@ export async function initSessionState(params: {
|
|||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||||
const storePath = resolveStorePath(sessionCfg?.store);
|
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
|
||||||
|
|
||||||
const sessionStore: Record<string, SessionEntry> =
|
const sessionStore: Record<string, SessionEntry> =
|
||||||
loadSessionStore(storePath);
|
loadSessionStore(storePath);
|
||||||
let sessionKey: string | undefined;
|
let sessionKey: string | undefined;
|
||||||
let sessionEntry: SessionEntry | undefined;
|
let sessionEntry: SessionEntry;
|
||||||
|
|
||||||
let sessionId: string | undefined;
|
let sessionId: string | undefined;
|
||||||
let isNewSession = false;
|
let isNewSession = false;
|
||||||
@ -154,30 +156,30 @@ export async function initSessionState(params: {
|
|||||||
queueDrop: baseEntry?.queueDrop,
|
queueDrop: baseEntry?.queueDrop,
|
||||||
displayName: baseEntry?.displayName,
|
displayName: baseEntry?.displayName,
|
||||||
chatType: baseEntry?.chatType,
|
chatType: baseEntry?.chatType,
|
||||||
surface: baseEntry?.surface,
|
provider: baseEntry?.provider,
|
||||||
subject: baseEntry?.subject,
|
subject: baseEntry?.subject,
|
||||||
room: baseEntry?.room,
|
room: baseEntry?.room,
|
||||||
space: baseEntry?.space,
|
space: baseEntry?.space,
|
||||||
};
|
};
|
||||||
if (groupResolution?.surface) {
|
if (groupResolution?.provider) {
|
||||||
const surface = groupResolution.surface;
|
const provider = groupResolution.provider;
|
||||||
const subject = ctx.GroupSubject?.trim();
|
const subject = ctx.GroupSubject?.trim();
|
||||||
const space = ctx.GroupSpace?.trim();
|
const space = ctx.GroupSpace?.trim();
|
||||||
const explicitRoom = ctx.GroupRoom?.trim();
|
const explicitRoom = ctx.GroupRoom?.trim();
|
||||||
const isRoomSurface = surface === "discord" || surface === "slack";
|
const isRoomProvider = provider === "discord" || provider === "slack";
|
||||||
const nextRoom =
|
const nextRoom =
|
||||||
explicitRoom ??
|
explicitRoom ??
|
||||||
(isRoomSurface && subject && subject.startsWith("#")
|
(isRoomProvider && subject && subject.startsWith("#")
|
||||||
? subject
|
? subject
|
||||||
: undefined);
|
: undefined);
|
||||||
const nextSubject = nextRoom ? undefined : subject;
|
const nextSubject = nextRoom ? undefined : subject;
|
||||||
sessionEntry.chatType = groupResolution.chatType ?? "group";
|
sessionEntry.chatType = groupResolution.chatType ?? "group";
|
||||||
sessionEntry.surface = surface;
|
sessionEntry.provider = provider;
|
||||||
if (nextSubject) sessionEntry.subject = nextSubject;
|
if (nextSubject) sessionEntry.subject = nextSubject;
|
||||||
if (nextRoom) sessionEntry.room = nextRoom;
|
if (nextRoom) sessionEntry.room = nextRoom;
|
||||||
if (space) sessionEntry.space = space;
|
if (space) sessionEntry.space = space;
|
||||||
sessionEntry.displayName = buildGroupDisplayName({
|
sessionEntry.displayName = buildGroupDisplayName({
|
||||||
surface: sessionEntry.surface,
|
provider: sessionEntry.provider,
|
||||||
subject: sessionEntry.subject,
|
subject: sessionEntry.subject,
|
||||||
room: sessionEntry.room,
|
room: sessionEntry.room,
|
||||||
space: sessionEntry.space,
|
space: sessionEntry.space,
|
||||||
|
|||||||
@ -24,7 +24,7 @@ describe("buildStatusMessage", () => {
|
|||||||
verboseLevel: "on",
|
verboseLevel: "on",
|
||||||
compactionCount: 2,
|
compactionCount: 2,
|
||||||
},
|
},
|
||||||
sessionKey: "main",
|
sessionKey: "agent:main:main",
|
||||||
sessionScope: "per-sender",
|
sessionScope: "per-sender",
|
||||||
storePath: "/tmp/sessions.json",
|
storePath: "/tmp/sessions.json",
|
||||||
resolvedThink: "medium",
|
resolvedThink: "medium",
|
||||||
@ -39,7 +39,7 @@ describe("buildStatusMessage", () => {
|
|||||||
expect(text).toContain("Agent: embedded pi");
|
expect(text).toContain("Agent: embedded pi");
|
||||||
expect(text).toContain("Runtime: direct");
|
expect(text).toContain("Runtime: direct");
|
||||||
expect(text).toContain("Context: 16k/32k (50%)");
|
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("compactions 2");
|
||||||
expect(text).toContain("Web: linked");
|
expect(text).toContain("Web: linked");
|
||||||
expect(text).toContain("heartbeat 45s");
|
expect(text).toContain("heartbeat 45s");
|
||||||
@ -70,7 +70,7 @@ describe("buildStatusMessage", () => {
|
|||||||
groupActivation: "always",
|
groupActivation: "always",
|
||||||
chatType: "group",
|
chatType: "group",
|
||||||
},
|
},
|
||||||
sessionKey: "whatsapp:group:123@g.us",
|
sessionKey: "agent:main:whatsapp:group:123@g.us",
|
||||||
sessionScope: "per-sender",
|
sessionScope: "per-sender",
|
||||||
webLinked: true,
|
webLinked: true,
|
||||||
});
|
});
|
||||||
@ -91,6 +91,8 @@ describe("buildStatusMessage", () => {
|
|||||||
const storePath = path.join(
|
const storePath = path.join(
|
||||||
dir,
|
dir,
|
||||||
".clawdbot",
|
".clawdbot",
|
||||||
|
"agents",
|
||||||
|
"main",
|
||||||
"sessions",
|
"sessions",
|
||||||
"sessions.json",
|
"sessions.json",
|
||||||
);
|
);
|
||||||
@ -98,6 +100,8 @@ describe("buildStatusMessage", () => {
|
|||||||
const logPath = path.join(
|
const logPath = path.join(
|
||||||
dir,
|
dir,
|
||||||
".clawdbot",
|
".clawdbot",
|
||||||
|
"agents",
|
||||||
|
"main",
|
||||||
"sessions",
|
"sessions",
|
||||||
`${sessionId}.jsonl`,
|
`${sessionId}.jsonl`,
|
||||||
);
|
);
|
||||||
@ -135,7 +139,7 @@ describe("buildStatusMessage", () => {
|
|||||||
totalTokens: 3, // would be wrong if cached prompt tokens exist
|
totalTokens: 3, // would be wrong if cached prompt tokens exist
|
||||||
contextTokens: 32_000,
|
contextTokens: 32_000,
|
||||||
},
|
},
|
||||||
sessionKey: "main",
|
sessionKey: "agent:main:main",
|
||||||
sessionScope: "per-sender",
|
sessionScope: "per-sender",
|
||||||
storePath,
|
storePath,
|
||||||
webLinked: true,
|
webLinked: true,
|
||||||
|
|||||||
@ -3,6 +3,8 @@ export type MsgContext = {
|
|||||||
From?: string;
|
From?: string;
|
||||||
To?: string;
|
To?: string;
|
||||||
SessionKey?: string;
|
SessionKey?: string;
|
||||||
|
/** Provider account id (multi-account). */
|
||||||
|
AccountId?: string;
|
||||||
MessageSid?: string;
|
MessageSid?: string;
|
||||||
ReplyToId?: string;
|
ReplyToId?: string;
|
||||||
ReplyToBody?: string;
|
ReplyToBody?: string;
|
||||||
@ -24,7 +26,8 @@ export type MsgContext = {
|
|||||||
SenderUsername?: string;
|
SenderUsername?: string;
|
||||||
SenderTag?: string;
|
SenderTag?: string;
|
||||||
SenderE164?: string;
|
SenderE164?: string;
|
||||||
Surface?: string;
|
/** Provider label (whatsapp|telegram|discord|imessage|...). */
|
||||||
|
Provider?: string;
|
||||||
WasMentioned?: boolean;
|
WasMentioned?: boolean;
|
||||||
CommandAuthorized?: boolean;
|
CommandAuthorized?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -154,8 +154,8 @@ export function registerCronCli(program: Command) {
|
|||||||
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
||||||
.option("--deliver", "Deliver agent output", false)
|
.option("--deliver", "Deliver agent output", false)
|
||||||
.option(
|
.option(
|
||||||
"--channel <channel>",
|
"--provider <provider>",
|
||||||
"Delivery channel (last|whatsapp|telegram|discord|slack|signal|imessage)",
|
"Delivery provider (last|whatsapp|telegram|discord|slack|signal|imessage)",
|
||||||
"last",
|
"last",
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
@ -255,7 +255,8 @@ export function registerCronCli(program: Command) {
|
|||||||
? timeoutSeconds
|
? timeoutSeconds
|
||||||
: undefined,
|
: undefined,
|
||||||
deliver: Boolean(opts.deliver),
|
deliver: Boolean(opts.deliver),
|
||||||
channel: typeof opts.channel === "string" ? opts.channel : "last",
|
provider:
|
||||||
|
typeof opts.provider === "string" ? opts.provider : "last",
|
||||||
to:
|
to:
|
||||||
typeof opts.to === "string" && opts.to.trim()
|
typeof opts.to === "string" && opts.to.trim()
|
||||||
? opts.to.trim()
|
? opts.to.trim()
|
||||||
@ -413,8 +414,8 @@ export function registerCronCli(program: Command) {
|
|||||||
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
||||||
.option("--deliver", "Deliver agent output", false)
|
.option("--deliver", "Deliver agent output", false)
|
||||||
.option(
|
.option(
|
||||||
"--channel <channel>",
|
"--provider <provider>",
|
||||||
"Delivery channel (last|whatsapp|telegram|discord|slack|signal|imessage)",
|
"Delivery provider (last|whatsapp|telegram|discord|slack|signal|imessage)",
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--to <dest>",
|
"--to <dest>",
|
||||||
@ -502,8 +503,8 @@ export function registerCronCli(program: Command) {
|
|||||||
? timeoutSeconds
|
? timeoutSeconds
|
||||||
: undefined,
|
: undefined,
|
||||||
deliver: Boolean(opts.deliver),
|
deliver: Boolean(opts.deliver),
|
||||||
channel:
|
provider:
|
||||||
typeof opts.channel === "string" ? opts.channel : undefined,
|
typeof opts.provider === "string" ? opts.provider : undefined,
|
||||||
to: typeof opts.to === "string" ? opts.to : undefined,
|
to: typeof opts.to === "string" ? opts.to : undefined,
|
||||||
bestEffortDeliver: Boolean(opts.bestEffortDeliver),
|
bestEffortDeliver: Boolean(opts.bestEffortDeliver),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { statusCommand } from "../commands/status.js";
|
|||||||
import { updateCommand } from "../commands/update.js";
|
import { updateCommand } from "../commands/update.js";
|
||||||
import {
|
import {
|
||||||
isNixMode,
|
isNixMode,
|
||||||
|
loadConfig,
|
||||||
migrateLegacyConfig,
|
migrateLegacyConfig,
|
||||||
readConfigFileSnapshot,
|
readConfigFileSnapshot,
|
||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
@ -21,6 +22,7 @@ import { danger, setVerbose } from "../globals.js";
|
|||||||
import { loginWeb, logoutWeb } from "../provider-web.js";
|
import { loginWeb, logoutWeb } from "../provider-web.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
|
import { resolveWhatsAppAccount } from "../web/accounts.js";
|
||||||
import { registerBrowserCli } from "./browser-cli.js";
|
import { registerBrowserCli } from "./browser-cli.js";
|
||||||
import { registerCanvasCli } from "./canvas-cli.js";
|
import { registerCanvasCli } from "./canvas-cli.js";
|
||||||
import { registerCronCli } from "./cron-cli.js";
|
import { registerCronCli } from "./cron-cli.js";
|
||||||
@ -324,11 +326,18 @@ export function buildProgram() {
|
|||||||
.description("Link your personal WhatsApp via QR (web provider)")
|
.description("Link your personal WhatsApp via QR (web provider)")
|
||||||
.option("--verbose", "Verbose connection logs", false)
|
.option("--verbose", "Verbose connection logs", false)
|
||||||
.option("--provider <provider>", "Provider alias (default: whatsapp)")
|
.option("--provider <provider>", "Provider alias (default: whatsapp)")
|
||||||
|
.option("--account <id>", "WhatsApp account id (accountId)")
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
try {
|
try {
|
||||||
const provider = opts.provider ?? "whatsapp";
|
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) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
@ -339,10 +348,20 @@ export function buildProgram() {
|
|||||||
.command("logout")
|
.command("logout")
|
||||||
.description("Clear cached WhatsApp Web credentials")
|
.description("Clear cached WhatsApp Web credentials")
|
||||||
.option("--provider <provider>", "Provider alias (default: whatsapp)")
|
.option("--provider <provider>", "Provider alias (default: whatsapp)")
|
||||||
|
.option("--account <id>", "WhatsApp account id (accountId)")
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
try {
|
try {
|
||||||
void opts.provider; // placeholder for future multi-provider; currently web only.
|
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) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(`Logout failed: ${String(err)}`));
|
defaultRuntime.error(danger(`Logout failed: ${String(err)}`));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
@ -372,6 +391,7 @@ export function buildProgram() {
|
|||||||
"--provider <provider>",
|
"--provider <provider>",
|
||||||
"Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)",
|
"Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)",
|
||||||
)
|
)
|
||||||
|
.option("--account <id>", "WhatsApp account id (accountId)")
|
||||||
.option("--dry-run", "Print payload and skip sending", false)
|
.option("--dry-run", "Print payload and skip sending", false)
|
||||||
.option("--json", "Output result as JSON", false)
|
.option("--json", "Output result as JSON", false)
|
||||||
.option("--verbose", "Verbose logging", false)
|
.option("--verbose", "Verbose logging", false)
|
||||||
@ -388,7 +408,14 @@ Examples:
|
|||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const deps = createDefaultDeps();
|
const deps = createDefaultDeps();
|
||||||
try {
|
try {
|
||||||
await sendCommand(opts, deps, defaultRuntime);
|
await sendCommand(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
account: opts.account as string | undefined,
|
||||||
|
},
|
||||||
|
deps,
|
||||||
|
defaultRuntime,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(String(err));
|
defaultRuntime.error(String(err));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
|
|||||||
@ -127,7 +127,7 @@ export async function agentViaGatewayCommand(
|
|||||||
sessionId: opts.sessionId,
|
sessionId: opts.sessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const channel = normalizeProvider(opts.provider) ?? "whatsapp";
|
const provider = normalizeProvider(opts.provider) ?? "whatsapp";
|
||||||
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
|
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
|
||||||
|
|
||||||
const response = await callGateway<GatewayAgentResponse>({
|
const response = await callGateway<GatewayAgentResponse>({
|
||||||
@ -139,7 +139,7 @@ export async function agentViaGatewayCommand(
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
thinking: opts.thinking,
|
thinking: opts.thinking,
|
||||||
deliver: Boolean(opts.deliver),
|
deliver: Boolean(opts.deliver),
|
||||||
channel,
|
provider,
|
||||||
timeout: timeoutSeconds,
|
timeout: timeoutSeconds,
|
||||||
lane: opts.lane,
|
lane: opts.lane,
|
||||||
extraSystemPrompt: opts.extraSystemPrompt,
|
extraSystemPrompt: opts.extraSystemPrompt,
|
||||||
|
|||||||
@ -59,7 +59,8 @@ type AgentCommandOpts = {
|
|||||||
json?: boolean;
|
json?: boolean;
|
||||||
timeout?: string;
|
timeout?: string;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
surface?: string;
|
/** Message provider context (webchat|voicewake|whatsapp|...). */
|
||||||
|
messageProvider?: string;
|
||||||
provider?: string; // delivery provider (whatsapp|telegram|...)
|
provider?: string; // delivery provider (whatsapp|telegram|...)
|
||||||
bestEffortDeliver?: boolean;
|
bestEffortDeliver?: boolean;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
@ -231,7 +232,7 @@ export async function agentCommand(
|
|||||||
cfg,
|
cfg,
|
||||||
entry: sessionEntry,
|
entry: sessionEntry,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
surface: sessionEntry?.surface,
|
provider: sessionEntry?.provider,
|
||||||
chatType: sessionEntry?.chatType,
|
chatType: sessionEntry?.chatType,
|
||||||
});
|
});
|
||||||
if (sendPolicy === "deny") {
|
if (sendPolicy === "deny") {
|
||||||
@ -379,8 +380,8 @@ export async function agentCommand(
|
|||||||
let fallbackProvider = provider;
|
let fallbackProvider = provider;
|
||||||
let fallbackModel = model;
|
let fallbackModel = model;
|
||||||
try {
|
try {
|
||||||
const surface =
|
const messageProvider =
|
||||||
opts.surface?.trim().toLowerCase() ||
|
opts.messageProvider?.trim().toLowerCase() ||
|
||||||
(() => {
|
(() => {
|
||||||
const raw = opts.provider?.trim().toLowerCase();
|
const raw = opts.provider?.trim().toLowerCase();
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
@ -394,7 +395,7 @@ export async function agentCommand(
|
|||||||
runEmbeddedPiAgent({
|
runEmbeddedPiAgent({
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
surface,
|
messageProvider,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
|||||||
180
src/commands/doctor-state-migrations.test.ts
Normal file
180
src/commands/doctor-state-migrations.test.ts
Normal file
@ -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/<id>/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<string, { sessionId: string }>;
|
||||||
|
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<string, { sessionId: string }>;
|
||||||
|
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<string, { sessionId: string }>;
|
||||||
|
expect(store["agent:main:work"]?.sessionId).toBe("b");
|
||||||
|
expect(store["agent:main:main"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
456
src/commands/doctor-state-migrations.ts
Normal file
456
src/commands/doctor-state-migrations.ts
Normal file
@ -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<string, SessionEntryLike>;
|
||||||
|
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<string, SessionEntryLike>, 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<string, SessionEntryLike>,
|
||||||
|
): 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<LegacyStateDetection> {
|
||||||
|
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<string, SessionEntryLike> = {};
|
||||||
|
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<string, SessionEntryLike> = {
|
||||||
|
...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<string, SessionEntry> = {};
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -135,6 +135,37 @@ vi.mock("./onboard-helpers.js", () => ({
|
|||||||
printWizardHeader: vi.fn(),
|
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", () => {
|
describe("doctor", () => {
|
||||||
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
|
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
|
||||||
readConfigFileSnapshot.mockResolvedValue({
|
readConfigFileSnapshot.mockResolvedValue({
|
||||||
|
|||||||
@ -34,6 +34,10 @@ import { defaultRuntime } from "../runtime.js";
|
|||||||
import { readTelegramAllowFromStore } from "../telegram/pairing-store.js";
|
import { readTelegramAllowFromStore } from "../telegram/pairing-store.js";
|
||||||
import { resolveTelegramToken } from "../telegram/token.js";
|
import { resolveTelegramToken } from "../telegram/token.js";
|
||||||
import { normalizeE164, resolveUserPath, sleep } from "../utils.js";
|
import { normalizeE164, resolveUserPath, sleep } from "../utils.js";
|
||||||
|
import {
|
||||||
|
detectLegacyStateMigrations,
|
||||||
|
runLegacyStateMigrations,
|
||||||
|
} from "./doctor-state-migrations.js";
|
||||||
import { healthCommand } from "./health.js";
|
import { healthCommand } from "./health.js";
|
||||||
import {
|
import {
|
||||||
applyWizardMetadata,
|
applyWizardMetadata,
|
||||||
@ -834,6 +838,29 @@ export async function doctorCommand(
|
|||||||
cfg = normalized.config;
|
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);
|
cfg = await maybeRepairSandboxImages(cfg, runtime);
|
||||||
|
|
||||||
await maybeMigrateLegacyGatewayService(cfg, runtime);
|
await maybeMigrateLegacyGatewayService(cfg, runtime);
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { info } from "../globals.js";
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { probeTelegram, type TelegramProbe } from "../telegram/probe.js";
|
import { probeTelegram, type TelegramProbe } from "../telegram/probe.js";
|
||||||
import { resolveTelegramToken } from "../telegram/token.js";
|
import { resolveTelegramToken } from "../telegram/token.js";
|
||||||
|
import { resolveWhatsAppAccount } from "../web/accounts.js";
|
||||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||||
import {
|
import {
|
||||||
getWebAuthAgeMs,
|
getWebAuthAgeMs,
|
||||||
@ -58,8 +59,9 @@ export async function getHealthSnapshot(
|
|||||||
timeoutMs?: number,
|
timeoutMs?: number,
|
||||||
): Promise<HealthSummary> {
|
): Promise<HealthSummary> {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const linked = await webAuthExists();
|
const account = resolveWhatsAppAccount({ cfg });
|
||||||
const authAgeMs = getWebAuthAgeMs();
|
const linked = await webAuthExists(account.authDir);
|
||||||
|
const authAgeMs = getWebAuthAgeMs(account.authDir);
|
||||||
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
|
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
|
||||||
const storePath = resolveStorePath(cfg.session?.store);
|
const storePath = resolveStorePath(cfg.session?.store);
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
@ -128,7 +130,9 @@ export async function healthCommand(
|
|||||||
: "Web: not linked (run clawdbot login)",
|
: "Web: not linked (run clawdbot login)",
|
||||||
);
|
);
|
||||||
if (summary.web.linked) {
|
if (summary.web.linked) {
|
||||||
logWebSelfId(runtime, true);
|
const cfg = loadConfig();
|
||||||
|
const account = resolveWhatsAppAccount({ cfg });
|
||||||
|
logWebSelfId(account.authDir, runtime, true);
|
||||||
}
|
}
|
||||||
if (summary.web.connect) {
|
if (summary.web.connect) {
|
||||||
const base = summary.web.connect.ok
|
const base = summary.web.connect.ok
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { DmPolicy } from "../config/types.js";
|
|||||||
import { loginWeb } from "../provider-web.js";
|
import { loginWeb } from "../provider-web.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { normalizeE164 } from "../utils.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 type { WizardPrompter } from "../wizard/prompts.js";
|
||||||
import { detectBinary } from "./onboard-helpers.js";
|
import { detectBinary } from "./onboard-helpers.js";
|
||||||
import type { ProviderChoice } from "./onboard-types.js";
|
import type { ProviderChoice } from "./onboard-types.js";
|
||||||
@ -29,7 +29,7 @@ async function pathExists(filePath: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function detectWhatsAppLinked(): Promise<boolean> {
|
async function detectWhatsAppLinked(): Promise<boolean> {
|
||||||
const credsPath = path.join(resolveWebAuthDir(), "creds.json");
|
const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json");
|
||||||
return await pathExists(credsPath);
|
return await pathExists(credsPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -550,7 +550,7 @@ export async function setupProviders(
|
|||||||
await prompter.note(
|
await prompter.note(
|
||||||
[
|
[
|
||||||
"Scan the QR with WhatsApp on your phone.",
|
"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"),
|
].join("\n"),
|
||||||
"WhatsApp linking",
|
"WhatsApp linking",
|
||||||
);
|
);
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export async function sendCommand(
|
|||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
media?: string;
|
media?: string;
|
||||||
gifPlayback?: boolean;
|
gifPlayback?: boolean;
|
||||||
|
account?: string;
|
||||||
},
|
},
|
||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
@ -173,6 +174,7 @@ export async function sendCommand(
|
|||||||
message: opts.message,
|
message: opts.message,
|
||||||
mediaUrl: opts.media,
|
mediaUrl: opts.media,
|
||||||
gifPlayback: opts.gifPlayback,
|
gifPlayback: opts.gifPlayback,
|
||||||
|
accountId: opts.account,
|
||||||
provider,
|
provider,
|
||||||
idempotencyKey: randomIdempotencyKey(),
|
idempotencyKey: randomIdempotencyKey(),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { info } from "../globals.js";
|
|||||||
import { buildProviderSummary } from "../infra/provider-summary.js";
|
import { buildProviderSummary } from "../infra/provider-summary.js";
|
||||||
import { peekSystemEvents } from "../infra/system-events.js";
|
import { peekSystemEvents } from "../infra/system-events.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { resolveWhatsAppAccount } from "../web/accounts.js";
|
||||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||||
import {
|
import {
|
||||||
getWebAuthAgeMs,
|
getWebAuthAgeMs,
|
||||||
@ -60,8 +61,9 @@ export type StatusSummary = {
|
|||||||
|
|
||||||
export async function getStatusSummary(): Promise<StatusSummary> {
|
export async function getStatusSummary(): Promise<StatusSummary> {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const linked = await webAuthExists();
|
const account = resolveWhatsAppAccount({ cfg });
|
||||||
const authAgeMs = getWebAuthAgeMs();
|
const linked = await webAuthExists(account.authDir);
|
||||||
|
const authAgeMs = getWebAuthAgeMs(account.authDir);
|
||||||
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
|
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
|
||||||
const providerSummary = await buildProviderSummary(cfg);
|
const providerSummary = await buildProviderSummary(cfg);
|
||||||
const queuedSystemEvents = peekSystemEvents();
|
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)})` : ""}`,
|
`Web session: ${summary.web.linked ? "linked" : "not linked"}${summary.web.linked ? ` (last refreshed ${formatAge(summary.web.authAgeMs)})` : ""}`,
|
||||||
);
|
);
|
||||||
if (summary.web.linked) {
|
if (summary.web.linked) {
|
||||||
logWebSelfId(runtime, true);
|
const cfg = loadConfig();
|
||||||
|
const account = resolveWhatsAppAccount({ cfg });
|
||||||
|
logWebSelfId(account.authDir, runtime, true);
|
||||||
}
|
}
|
||||||
runtime.log(info("System:"));
|
runtime.log(info("System:"));
|
||||||
for (const line of summary.providerSummary) {
|
for (const line of summary.providerSummary) {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { ClawdbotConfig } from "./config.js";
|
import type { ClawdbotConfig } from "./config.js";
|
||||||
|
|
||||||
export type GroupPolicySurface = "whatsapp" | "telegram" | "imessage";
|
export type GroupPolicyProvider = "whatsapp" | "telegram" | "imessage";
|
||||||
|
|
||||||
export type ProviderGroupConfig = {
|
export type ProviderGroupConfig = {
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
@ -17,21 +17,21 @@ type ProviderGroups = Record<string, ProviderGroupConfig>;
|
|||||||
|
|
||||||
function resolveProviderGroups(
|
function resolveProviderGroups(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
surface: GroupPolicySurface,
|
provider: GroupPolicyProvider,
|
||||||
): ProviderGroups | undefined {
|
): ProviderGroups | undefined {
|
||||||
if (surface === "whatsapp") return cfg.whatsapp?.groups;
|
if (provider === "whatsapp") return cfg.whatsapp?.groups;
|
||||||
if (surface === "telegram") return cfg.telegram?.groups;
|
if (provider === "telegram") return cfg.telegram?.groups;
|
||||||
if (surface === "imessage") return cfg.imessage?.groups;
|
if (provider === "imessage") return cfg.imessage?.groups;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveProviderGroupPolicy(params: {
|
export function resolveProviderGroupPolicy(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
surface: GroupPolicySurface;
|
provider: GroupPolicyProvider;
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
}): ProviderGroupPolicy {
|
}): ProviderGroupPolicy {
|
||||||
const { cfg, surface } = params;
|
const { cfg, provider } = params;
|
||||||
const groups = resolveProviderGroups(cfg, surface);
|
const groups = resolveProviderGroups(cfg, provider);
|
||||||
const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0);
|
const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0);
|
||||||
const normalizedId = params.groupId?.trim();
|
const normalizedId = params.groupId?.trim();
|
||||||
const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined;
|
const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined;
|
||||||
@ -54,7 +54,7 @@ export function resolveProviderGroupPolicy(params: {
|
|||||||
|
|
||||||
export function resolveProviderGroupRequireMention(params: {
|
export function resolveProviderGroupRequireMention(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
surface: GroupPolicySurface;
|
provider: GroupPolicyProvider;
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
requireMentionOverride?: boolean;
|
requireMentionOverride?: boolean;
|
||||||
overrideOrder?: "before-config" | "after-config";
|
overrideOrder?: "before-config" | "after-config";
|
||||||
|
|||||||
@ -33,30 +33,30 @@ describe("sessions", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefixes group keys with surface when available", () => {
|
it("prefixes group keys with provider when available", () => {
|
||||||
expect(
|
expect(
|
||||||
deriveSessionKey("per-sender", {
|
deriveSessionKey("per-sender", {
|
||||||
From: "12345-678@g.us",
|
From: "12345-678@g.us",
|
||||||
ChatType: "group",
|
ChatType: "group",
|
||||||
Surface: "whatsapp",
|
Provider: "whatsapp",
|
||||||
}),
|
}),
|
||||||
).toBe("whatsapp:group:12345-678@g.us");
|
).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(
|
expect(
|
||||||
resolveSessionKey(
|
resolveSessionKey(
|
||||||
"per-sender",
|
"per-sender",
|
||||||
{ From: "group:discord:12345", ChatType: "group" },
|
{ From: "group:discord:12345", ChatType: "group" },
|
||||||
"main",
|
"main",
|
||||||
),
|
),
|
||||||
).toBe("discord:group:12345");
|
).toBe("agent:main:discord:group:12345");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds discord display name with guild+channel slugs", () => {
|
it("builds discord display name with guild+channel slugs", () => {
|
||||||
expect(
|
expect(
|
||||||
buildGroupDisplayName({
|
buildGroupDisplayName({
|
||||||
surface: "discord",
|
provider: "discord",
|
||||||
room: "#general",
|
room: "#general",
|
||||||
space: "friends-of-clawd",
|
space: "friends-of-clawd",
|
||||||
id: "123",
|
id: "123",
|
||||||
@ -66,22 +66,24 @@ describe("sessions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("collapses direct chats to main by default", () => {
|
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", () => {
|
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", () => {
|
it("maps direct chats to main key when provided", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveSessionKey("per-sender", { From: "whatsapp:+1555" }, "main"),
|
resolveSessionKey("per-sender", { From: "whatsapp:+1555" }, "main"),
|
||||||
).toBe("main");
|
).toBe("agent:main:main");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses custom main key when provided", () => {
|
it("uses custom main key when provided", () => {
|
||||||
expect(resolveSessionKey("per-sender", { From: "+1555" }, "primary")).toBe(
|
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", () => {
|
it("leaves groups untouched even with main key", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveSessionKey("per-sender", { From: "12345-678@g.us" }, "main"),
|
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 dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
||||||
const storePath = path.join(dir, "sessions.json");
|
const storePath = path.join(dir, "sessions.json");
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
storePath,
|
storePath,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
main: {
|
[mainSessionKey]: {
|
||||||
sessionId: "sess-1",
|
sessionId: "sess-1",
|
||||||
updatedAt: 123,
|
updatedAt: 123,
|
||||||
systemSent: true,
|
systemSent: true,
|
||||||
@ -117,16 +120,16 @@ describe("sessions", () => {
|
|||||||
|
|
||||||
await updateLastRoute({
|
await updateLastRoute({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: "main",
|
sessionKey: mainSessionKey,
|
||||||
channel: "telegram",
|
provider: "telegram",
|
||||||
to: " 12345 ",
|
to: " 12345 ",
|
||||||
});
|
});
|
||||||
|
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
expect(store.main?.sessionId).toBe("sess-1");
|
expect(store[mainSessionKey]?.sessionId).toBe("sess-1");
|
||||||
expect(store.main?.updatedAt).toBeGreaterThanOrEqual(123);
|
expect(store[mainSessionKey]?.updatedAt).toBeGreaterThanOrEqual(123);
|
||||||
expect(store.main?.lastChannel).toBe("telegram");
|
expect(store[mainSessionKey]?.lastProvider).toBe("telegram");
|
||||||
expect(store.main?.lastTo).toBe("12345");
|
expect(store[mainSessionKey]?.lastTo).toBe("12345");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives session transcripts dir from CLAWDBOT_STATE_DIR", () => {
|
it("derives session transcripts dir from CLAWDBOT_STATE_DIR", () => {
|
||||||
@ -134,7 +137,7 @@ describe("sessions", () => {
|
|||||||
{ CLAWDBOT_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv,
|
{ CLAWDBOT_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv,
|
||||||
() => "/home/ignored",
|
() => "/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", () => {
|
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,
|
{ CLAWDIS_STATE_DIR: "/legacy/state" } as NodeJS.ProcessEnv,
|
||||||
() => "/home/ignored",
|
() => "/home/ignored",
|
||||||
);
|
);
|
||||||
expect(dir).toBe("/legacy/state/sessions");
|
expect(dir).toBe("/legacy/state/agents/main/sessions");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,6 +6,13 @@ import path from "node:path";
|
|||||||
import type { Skill } from "@mariozechner/pi-coding-agent";
|
import type { Skill } from "@mariozechner/pi-coding-agent";
|
||||||
import JSON5 from "json5";
|
import JSON5 from "json5";
|
||||||
import type { MsgContext } from "../auto-reply/templating.js";
|
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 { normalizeE164 } from "../utils.js";
|
||||||
import { resolveStateDir } from "./paths.js";
|
import { resolveStateDir } from "./paths.js";
|
||||||
|
|
||||||
@ -59,11 +66,11 @@ export type SessionEntry = {
|
|||||||
contextTokens?: number;
|
contextTokens?: number;
|
||||||
compactionCount?: number;
|
compactionCount?: number;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
surface?: string;
|
provider?: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
room?: string;
|
room?: string;
|
||||||
space?: string;
|
space?: string;
|
||||||
lastChannel?:
|
lastProvider?:
|
||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
| "telegram"
|
| "telegram"
|
||||||
| "discord"
|
| "discord"
|
||||||
@ -72,13 +79,14 @@ export type SessionEntry = {
|
|||||||
| "imessage"
|
| "imessage"
|
||||||
| "webchat";
|
| "webchat";
|
||||||
lastTo?: string;
|
lastTo?: string;
|
||||||
|
lastAccountId?: string;
|
||||||
skillsSnapshot?: SessionSkillSnapshot;
|
skillsSnapshot?: SessionSkillSnapshot;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GroupKeyResolution = {
|
export type GroupKeyResolution = {
|
||||||
key: string;
|
key: string;
|
||||||
legacyKey?: string;
|
legacyKey?: string;
|
||||||
surface?: string;
|
provider?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
chatType?: SessionChatType;
|
chatType?: SessionChatType;
|
||||||
};
|
};
|
||||||
@ -89,26 +97,45 @@ export type SessionSkillSnapshot = {
|
|||||||
resolvedSkills?: Skill[];
|
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(
|
export function resolveSessionTranscriptsDir(
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
homedir: () => string = os.homedir,
|
homedir: () => string = os.homedir,
|
||||||
): string {
|
): string {
|
||||||
return path.join(resolveStateDir(env, homedir), "sessions");
|
return resolveAgentSessionsDir(DEFAULT_AGENT_ID, env, homedir);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultSessionStorePath(): string {
|
export function resolveDefaultSessionStorePath(agentId?: string): string {
|
||||||
return path.join(resolveSessionTranscriptsDir(), "sessions.json");
|
return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
|
||||||
}
|
}
|
||||||
export const DEFAULT_RESET_TRIGGER = "/new";
|
export const DEFAULT_RESET_TRIGGER = "/new";
|
||||||
export const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"];
|
export const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"];
|
||||||
export const DEFAULT_IDLE_MINUTES = 60;
|
export const DEFAULT_IDLE_MINUTES = 60;
|
||||||
|
|
||||||
export function resolveSessionTranscriptPath(sessionId: string): string {
|
export function resolveSessionTranscriptPath(
|
||||||
return path.join(resolveSessionTranscriptsDir(), `${sessionId}.jsonl`);
|
sessionId: string,
|
||||||
|
agentId?: string,
|
||||||
|
): string {
|
||||||
|
return path.join(resolveAgentSessionsDir(agentId), `${sessionId}.jsonl`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveStorePath(store?: string) {
|
export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
|
||||||
if (!store) return resolveDefaultSessionStorePath();
|
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("~"))
|
if (store.startsWith("~"))
|
||||||
return path.resolve(store.replace("~", os.homedir()));
|
return path.resolve(store.replace("~", os.homedir()));
|
||||||
return path.resolve(store);
|
return path.resolve(store);
|
||||||
@ -116,9 +143,32 @@ export function resolveStorePath(store?: string) {
|
|||||||
|
|
||||||
export function resolveMainSessionKey(cfg?: {
|
export function resolveMainSessionKey(cfg?: {
|
||||||
session?: { scope?: SessionScope; mainKey?: string };
|
session?: { scope?: SessionScope; mainKey?: string };
|
||||||
|
routing?: { defaultAgentId?: string };
|
||||||
}): string {
|
}): string {
|
||||||
if (cfg?.session?.scope === "global") return "global";
|
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) {
|
function normalizeGroupLabel(raw?: string) {
|
||||||
@ -137,14 +187,14 @@ function shortenGroupId(value?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildGroupDisplayName(params: {
|
export function buildGroupDisplayName(params: {
|
||||||
surface?: string;
|
provider?: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
room?: string;
|
room?: string;
|
||||||
space?: string;
|
space?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
key: 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 room = params.room?.trim();
|
||||||
const space = params.space?.trim();
|
const space = params.space?.trim();
|
||||||
const subject = params.subject?.trim();
|
const subject = params.subject?.trim();
|
||||||
@ -169,7 +219,7 @@ export function buildGroupDisplayName(params: {
|
|||||||
) {
|
) {
|
||||||
token = `g-${token}`;
|
token = `g-${token}`;
|
||||||
}
|
}
|
||||||
return token ? `${surfaceKey}:${token}` : surfaceKey;
|
return token ? `${providerKey}:${token}` : providerKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveGroupSessionKey(
|
export function resolveGroupSessionKey(
|
||||||
@ -186,13 +236,13 @@ export function resolveGroupSessionKey(
|
|||||||
from.includes(":channel:");
|
from.includes(":channel:");
|
||||||
if (!isGroup) return null;
|
if (!isGroup) return null;
|
||||||
|
|
||||||
const surfaceHint = ctx.Surface?.trim().toLowerCase();
|
const providerHint = ctx.Provider?.trim().toLowerCase();
|
||||||
const hasLegacyGroupPrefix = from.startsWith("group:");
|
const hasLegacyGroupPrefix = from.startsWith("group:");
|
||||||
const raw = (
|
const raw = (
|
||||||
hasLegacyGroupPrefix ? from.slice("group:".length) : from
|
hasLegacyGroupPrefix ? from.slice("group:".length) : from
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
let surface: string | undefined;
|
let provider: string | undefined;
|
||||||
let kind: "group" | "channel" | undefined;
|
let kind: "group" | "channel" | undefined;
|
||||||
let id = "";
|
let id = "";
|
||||||
|
|
||||||
@ -203,7 +253,7 @@ export function resolveGroupSessionKey(
|
|||||||
|
|
||||||
const parseParts = (parts: string[]) => {
|
const parseParts = (parts: string[]) => {
|
||||||
if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) {
|
if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) {
|
||||||
surface = parts[0];
|
provider = parts[0];
|
||||||
if (parts.length >= 3) {
|
if (parts.length >= 3) {
|
||||||
const kindCandidate = parts[1];
|
const kindCandidate = parts[1];
|
||||||
if (["group", "channel"].includes(kindCandidate)) {
|
if (["group", "channel"].includes(kindCandidate)) {
|
||||||
@ -239,8 +289,8 @@ export function resolveGroupSessionKey(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedSurface = surface ?? surfaceHint;
|
const resolvedProvider = provider ?? providerHint;
|
||||||
if (!resolvedSurface) {
|
if (!resolvedProvider) {
|
||||||
const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`;
|
const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`;
|
||||||
return {
|
return {
|
||||||
key: legacy,
|
key: legacy,
|
||||||
@ -251,7 +301,7 @@ export function resolveGroupSessionKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resolvedKind = kind === "channel" ? "channel" : "group";
|
const resolvedKind = kind === "channel" ? "channel" : "group";
|
||||||
const key = `${resolvedSurface}:${resolvedKind}:${id || raw || from}`;
|
const key = `${resolvedProvider}:${resolvedKind}:${id || raw || from}`;
|
||||||
let legacyKey: string | undefined;
|
let legacyKey: string | undefined;
|
||||||
if (hasLegacyGroupPrefix || from.includes("@g.us")) {
|
if (hasLegacyGroupPrefix || from.includes("@g.us")) {
|
||||||
legacyKey = `group:${id || raw || from}`;
|
legacyKey = `group:${id || raw || from}`;
|
||||||
@ -260,7 +310,7 @@ export function resolveGroupSessionKey(
|
|||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
legacyKey,
|
legacyKey,
|
||||||
surface: resolvedSurface,
|
provider: resolvedProvider,
|
||||||
id: id || raw || from,
|
id: id || raw || from,
|
||||||
chatType: resolvedKind === "channel" ? "room" : "group",
|
chatType: resolvedKind === "channel" ? "room" : "group",
|
||||||
};
|
};
|
||||||
@ -323,10 +373,11 @@ export async function saveSessionStore(
|
|||||||
export async function updateLastRoute(params: {
|
export async function updateLastRoute(params: {
|
||||||
storePath: string;
|
storePath: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
channel: SessionEntry["lastChannel"];
|
provider: SessionEntry["lastProvider"];
|
||||||
to?: string;
|
to?: string;
|
||||||
|
accountId?: string;
|
||||||
}) {
|
}) {
|
||||||
const { storePath, sessionKey, channel, to } = params;
|
const { storePath, sessionKey, provider, to, accountId } = params;
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
const existing = store[sessionKey];
|
const existing = store[sessionKey];
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@ -349,13 +400,16 @@ export async function updateLastRoute(params: {
|
|||||||
contextTokens: existing?.contextTokens,
|
contextTokens: existing?.contextTokens,
|
||||||
displayName: existing?.displayName,
|
displayName: existing?.displayName,
|
||||||
chatType: existing?.chatType,
|
chatType: existing?.chatType,
|
||||||
surface: existing?.surface,
|
provider: existing?.provider,
|
||||||
subject: existing?.subject,
|
subject: existing?.subject,
|
||||||
room: existing?.room,
|
room: existing?.room,
|
||||||
space: existing?.space,
|
space: existing?.space,
|
||||||
skillsSnapshot: existing?.skillsSnapshot,
|
skillsSnapshot: existing?.skillsSnapshot,
|
||||||
lastChannel: channel,
|
lastProvider: provider,
|
||||||
lastTo: to?.trim() ? to.trim() : undefined,
|
lastTo: to?.trim() ? to.trim() : undefined,
|
||||||
|
lastAccountId: accountId?.trim()
|
||||||
|
? accountId.trim()
|
||||||
|
: existing?.lastAccountId,
|
||||||
};
|
};
|
||||||
store[sessionKey] = next;
|
store[sessionKey] = next;
|
||||||
await saveSessionStore(storePath, store);
|
await saveSessionStore(storePath, store);
|
||||||
@ -384,12 +438,16 @@ export function resolveSessionKey(
|
|||||||
if (explicit) return explicit;
|
if (explicit) return explicit;
|
||||||
const raw = deriveSessionKey(scope, ctx);
|
const raw = deriveSessionKey(scope, ctx);
|
||||||
if (scope === "global") return raw;
|
if (scope === "global") return raw;
|
||||||
// Default to a single shared direct-chat session called "main"; groups stay isolated.
|
const canonicalMainKey =
|
||||||
const canonical = (mainKey ?? "main").trim() || "main";
|
(mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY;
|
||||||
|
const canonical = buildAgentMainSessionKey({
|
||||||
|
agentId: DEFAULT_AGENT_ID,
|
||||||
|
mainKey: canonicalMainKey,
|
||||||
|
});
|
||||||
const isGroup =
|
const isGroup =
|
||||||
raw.startsWith("group:") ||
|
raw.startsWith("group:") ||
|
||||||
raw.includes(":group:") ||
|
raw.includes(":group:") ||
|
||||||
raw.includes(":channel:");
|
raw.includes(":channel:");
|
||||||
if (!isGroup) return canonical;
|
if (!isGroup) return canonical;
|
||||||
return raw;
|
return `agent:${DEFAULT_AGENT_ID}:${raw}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
|||||||
|
|
||||||
export type SessionSendPolicyAction = "allow" | "deny";
|
export type SessionSendPolicyAction = "allow" | "deny";
|
||||||
export type SessionSendPolicyMatch = {
|
export type SessionSendPolicyMatch = {
|
||||||
surface?: string;
|
provider?: string;
|
||||||
chatType?: "direct" | "group" | "room";
|
chatType?: "direct" | "group" | "room";
|
||||||
keyPrefix?: string;
|
keyPrefix?: string;
|
||||||
};
|
};
|
||||||
@ -178,7 +178,7 @@ export type HookMappingConfig = {
|
|||||||
messageTemplate?: string;
|
messageTemplate?: string;
|
||||||
textTemplate?: string;
|
textTemplate?: string;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
channel?:
|
provider?:
|
||||||
| "last"
|
| "last"
|
||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
| "telegram"
|
| "telegram"
|
||||||
@ -506,7 +506,7 @@ export type QueueMode =
|
|||||||
| "interrupt";
|
| "interrupt";
|
||||||
export type QueueDropPolicy = "old" | "new" | "summarize";
|
export type QueueDropPolicy = "old" | "new" | "summarize";
|
||||||
|
|
||||||
export type QueueModeBySurface = {
|
export type QueueModeByProvider = {
|
||||||
whatsapp?: QueueMode;
|
whatsapp?: QueueMode;
|
||||||
telegram?: QueueMode;
|
telegram?: QueueMode;
|
||||||
discord?: QueueMode;
|
discord?: QueueMode;
|
||||||
@ -552,8 +552,8 @@ export type RoutingConfig = {
|
|||||||
bindings?: Array<{
|
bindings?: Array<{
|
||||||
agentId: string;
|
agentId: string;
|
||||||
match: {
|
match: {
|
||||||
surface: string;
|
provider: string;
|
||||||
surfaceAccountId?: string;
|
accountId?: string;
|
||||||
peer?: { kind: "dm" | "group" | "channel"; id: string };
|
peer?: { kind: "dm" | "group" | "channel"; id: string };
|
||||||
guildId?: string;
|
guildId?: string;
|
||||||
teamId?: string;
|
teamId?: string;
|
||||||
@ -561,7 +561,7 @@ export type RoutingConfig = {
|
|||||||
}>;
|
}>;
|
||||||
queue?: {
|
queue?: {
|
||||||
mode?: QueueMode;
|
mode?: QueueMode;
|
||||||
bySurface?: QueueModeBySurface;
|
byProvider?: QueueModeByProvider;
|
||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
cap?: number;
|
cap?: number;
|
||||||
drop?: QueueDropPolicy;
|
drop?: QueueDropPolicy;
|
||||||
@ -902,7 +902,7 @@ export type ClawdbotConfig = {
|
|||||||
elevated?: {
|
elevated?: {
|
||||||
/** Enable or disable elevated mode (default: true). */
|
/** Enable or disable elevated mode (default: true). */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Approved senders for /elevated (per-surface allowlists). */
|
/** Approved senders for /elevated (per-provider allowlists). */
|
||||||
allowFrom?: AgentElevatedAllowFromConfig;
|
allowFrom?: AgentElevatedAllowFromConfig;
|
||||||
};
|
};
|
||||||
/** Optional sandbox settings for non-main sessions. */
|
/** Optional sandbox settings for non-main sessions. */
|
||||||
|
|||||||
@ -130,7 +130,7 @@ const SessionSchema = z
|
|||||||
action: z.union([z.literal("allow"), z.literal("deny")]),
|
action: z.union([z.literal("allow"), z.literal("deny")]),
|
||||||
match: z
|
match: z
|
||||||
.object({
|
.object({
|
||||||
surface: z.string().optional(),
|
provider: z.string().optional(),
|
||||||
chatType: z
|
chatType: z
|
||||||
.union([
|
.union([
|
||||||
z.literal("direct"),
|
z.literal("direct"),
|
||||||
@ -240,8 +240,8 @@ const RoutingSchema = z
|
|||||||
z.object({
|
z.object({
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
match: z.object({
|
match: z.object({
|
||||||
surface: z.string(),
|
provider: z.string(),
|
||||||
surfaceAccountId: z.string().optional(),
|
accountId: z.string().optional(),
|
||||||
peer: z
|
peer: z
|
||||||
.object({
|
.object({
|
||||||
kind: z.union([
|
kind: z.union([
|
||||||
@ -261,7 +261,7 @@ const RoutingSchema = z
|
|||||||
queue: z
|
queue: z
|
||||||
.object({
|
.object({
|
||||||
mode: QueueModeSchema.optional(),
|
mode: QueueModeSchema.optional(),
|
||||||
bySurface: QueueModeBySurfaceSchema,
|
byProvider: QueueModeBySurfaceSchema,
|
||||||
debounceMs: z.number().int().nonnegative().optional(),
|
debounceMs: z.number().int().nonnegative().optional(),
|
||||||
cap: z.number().int().positive().optional(),
|
cap: z.number().int().positive().optional(),
|
||||||
drop: QueueDropSchema.optional(),
|
drop: QueueDropSchema.optional(),
|
||||||
@ -288,7 +288,7 @@ const HookMappingSchema = z
|
|||||||
messageTemplate: z.string().optional(),
|
messageTemplate: z.string().optional(),
|
||||||
textTemplate: z.string().optional(),
|
textTemplate: z.string().optional(),
|
||||||
deliver: z.boolean().optional(),
|
deliver: z.boolean().optional(),
|
||||||
channel: z
|
provider: z
|
||||||
.union([
|
.union([
|
||||||
z.literal("last"),
|
z.literal("last"),
|
||||||
z.literal("whatsapp"),
|
z.literal("whatsapp"),
|
||||||
|
|||||||
@ -9,22 +9,22 @@ type SchemaLike = {
|
|||||||
const?: unknown;
|
const?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChannelSchema = {
|
type ProviderSchema = {
|
||||||
anyOf?: Array<{ const?: unknown }>;
|
anyOf?: Array<{ const?: unknown }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function extractCronChannels(schema: SchemaLike): string[] {
|
function extractCronProviders(schema: SchemaLike): string[] {
|
||||||
const union = schema.anyOf ?? [];
|
const union = schema.anyOf ?? [];
|
||||||
const payloadWithChannel = union.find((entry) =>
|
const payloadWithProvider = union.find((entry) =>
|
||||||
Boolean(entry?.properties && "channel" in entry.properties),
|
Boolean(entry?.properties && "provider" in entry.properties),
|
||||||
);
|
);
|
||||||
const channelSchema = payloadWithChannel?.properties
|
const providerSchema = payloadWithProvider?.properties
|
||||||
? (payloadWithChannel.properties.channel as ChannelSchema)
|
? (payloadWithProvider.properties.provider as ProviderSchema)
|
||||||
: undefined;
|
: undefined;
|
||||||
const channels = (channelSchema?.anyOf ?? [])
|
const providers = (providerSchema?.anyOf ?? [])
|
||||||
.map((entry) => entry?.const)
|
.map((entry) => entry?.const)
|
||||||
.filter((value): value is string => typeof value === "string");
|
.filter((value): value is string => typeof value === "string");
|
||||||
return channels;
|
return providers;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UI_FILES = [
|
const UI_FILES = [
|
||||||
@ -36,28 +36,28 @@ const UI_FILES = [
|
|||||||
const SWIFT_FILES = ["apps/macos/Sources/Clawdbot/GatewayConnection.swift"];
|
const SWIFT_FILES = ["apps/macos/Sources/Clawdbot/GatewayConnection.swift"];
|
||||||
|
|
||||||
describe("cron protocol conformance", () => {
|
describe("cron protocol conformance", () => {
|
||||||
it("ui + swift include all cron channels from gateway schema", async () => {
|
it("ui + swift include all cron providers from gateway schema", async () => {
|
||||||
const channels = extractCronChannels(CronPayloadSchema as SchemaLike);
|
const providers = extractCronProviders(CronPayloadSchema as SchemaLike);
|
||||||
expect(channels.length).toBeGreaterThan(0);
|
expect(providers.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
for (const relPath of UI_FILES) {
|
for (const relPath of UI_FILES) {
|
||||||
const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
|
const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
|
||||||
for (const channel of channels) {
|
for (const provider of providers) {
|
||||||
expect(
|
expect(
|
||||||
content.includes(`"${channel}"`),
|
content.includes(`"${provider}"`),
|
||||||
`${relPath} missing ${channel}`,
|
`${relPath} missing ${provider}`,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const relPath of SWIFT_FILES) {
|
for (const relPath of SWIFT_FILES) {
|
||||||
const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
|
const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
|
||||||
for (const channel of channels) {
|
for (const provider of providers) {
|
||||||
const pattern = new RegExp(`\\bcase\\s+${channel}\\b`);
|
const pattern = new RegExp(`\\bcase\\s+${provider}\\b`);
|
||||||
expect(
|
expect(
|
||||||
pattern.test(content),
|
pattern.test(content),
|
||||||
`${relPath} missing case ${channel}`,
|
`${relPath} missing case ${provider}`,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,10 +42,10 @@ async function writeSessionStore(home: string) {
|
|||||||
storePath,
|
storePath,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
main: {
|
"agent:main:main": {
|
||||||
sessionId: "main-session",
|
sessionId: "main-session",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
lastChannel: "webchat",
|
lastProvider: "webchat",
|
||||||
lastTo: "",
|
lastTo: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -224,7 +224,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
kind: "agentTurn",
|
kind: "agentTurn",
|
||||||
message: "do it",
|
message: "do it",
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "whatsapp",
|
provider: "whatsapp",
|
||||||
bestEffortDeliver: false,
|
bestEffortDeliver: false,
|
||||||
}),
|
}),
|
||||||
message: "do it",
|
message: "do it",
|
||||||
@ -264,7 +264,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
kind: "agentTurn",
|
kind: "agentTurn",
|
||||||
message: "do it",
|
message: "do it",
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "whatsapp",
|
provider: "whatsapp",
|
||||||
bestEffortDeliver: true,
|
bestEffortDeliver: true,
|
||||||
}),
|
}),
|
||||||
message: "do it",
|
message: "do it",
|
||||||
@ -309,7 +309,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
kind: "agentTurn",
|
kind: "agentTurn",
|
||||||
message: "do it",
|
message: "do it",
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "telegram",
|
provider: "telegram",
|
||||||
to: "123",
|
to: "123",
|
||||||
}),
|
}),
|
||||||
message: "do it",
|
message: "do it",
|
||||||
@ -361,7 +361,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
kind: "agentTurn",
|
kind: "agentTurn",
|
||||||
message: "do it",
|
message: "do it",
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "discord",
|
provider: "discord",
|
||||||
to: "channel:1122",
|
to: "channel:1122",
|
||||||
}),
|
}),
|
||||||
message: "do it",
|
message: "do it",
|
||||||
@ -406,7 +406,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
kind: "agentTurn",
|
kind: "agentTurn",
|
||||||
message: "do it",
|
message: "do it",
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "telegram",
|
provider: "telegram",
|
||||||
to: "123",
|
to: "123",
|
||||||
}),
|
}),
|
||||||
message: "do it",
|
message: "do it",
|
||||||
@ -450,7 +450,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
kind: "agentTurn",
|
kind: "agentTurn",
|
||||||
message: "do it",
|
message: "do it",
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "whatsapp",
|
provider: "whatsapp",
|
||||||
to: "+1234",
|
to: "+1234",
|
||||||
}),
|
}),
|
||||||
message: "do it",
|
message: "do it",
|
||||||
@ -493,7 +493,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
kind: "agentTurn",
|
kind: "agentTurn",
|
||||||
message: "do it",
|
message: "do it",
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "telegram",
|
provider: "telegram",
|
||||||
to: "123",
|
to: "123",
|
||||||
}),
|
}),
|
||||||
message: "do it",
|
message: "do it",
|
||||||
@ -537,7 +537,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
kind: "agentTurn",
|
kind: "agentTurn",
|
||||||
message: "do it",
|
message: "do it",
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "telegram",
|
provider: "telegram",
|
||||||
to: "123",
|
to: "123",
|
||||||
}),
|
}),
|
||||||
message: "do it",
|
message: "do it",
|
||||||
@ -585,7 +585,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
kind: "agentTurn",
|
kind: "agentTurn",
|
||||||
message: "do it",
|
message: "do it",
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "telegram",
|
provider: "telegram",
|
||||||
to: "123",
|
to: "123",
|
||||||
}),
|
}),
|
||||||
message: "do it",
|
message: "do it",
|
||||||
|
|||||||
@ -29,6 +29,8 @@ import type { ClawdbotConfig } from "../config/config.js";
|
|||||||
import {
|
import {
|
||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
resolveAgentIdFromSessionKey,
|
||||||
|
resolveMainSessionKey,
|
||||||
resolveSessionTranscriptPath,
|
resolveSessionTranscriptPath,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
@ -87,7 +89,7 @@ function isHeartbeatOnlyResponse(
|
|||||||
function resolveDeliveryTarget(
|
function resolveDeliveryTarget(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
jobPayload: {
|
jobPayload: {
|
||||||
channel?:
|
provider?:
|
||||||
| "last"
|
| "last"
|
||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
| "telegram"
|
| "telegram"
|
||||||
@ -98,36 +100,37 @@ function resolveDeliveryTarget(
|
|||||||
to?: string;
|
to?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const requestedChannel =
|
const requestedProvider =
|
||||||
typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
|
typeof jobPayload.provider === "string" ? jobPayload.provider : "last";
|
||||||
const explicitTo =
|
const explicitTo =
|
||||||
typeof jobPayload.to === "string" && jobPayload.to.trim()
|
typeof jobPayload.to === "string" && jobPayload.to.trim()
|
||||||
? jobPayload.to.trim()
|
? jobPayload.to.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
const mainSessionKey = resolveMainSessionKey(cfg);
|
||||||
const storePath = resolveStorePath(sessionCfg?.store);
|
const agentId = resolveAgentIdFromSessionKey(mainSessionKey);
|
||||||
|
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
const main = store[mainKey];
|
const main = store[mainSessionKey];
|
||||||
const lastChannel =
|
const lastProvider =
|
||||||
main?.lastChannel && main.lastChannel !== "webchat"
|
main?.lastProvider && main.lastProvider !== "webchat"
|
||||||
? main.lastChannel
|
? main.lastProvider
|
||||||
: undefined;
|
: undefined;
|
||||||
const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : "";
|
const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : "";
|
||||||
|
|
||||||
const channel = (() => {
|
const provider = (() => {
|
||||||
if (
|
if (
|
||||||
requestedChannel === "whatsapp" ||
|
requestedProvider === "whatsapp" ||
|
||||||
requestedChannel === "telegram" ||
|
requestedProvider === "telegram" ||
|
||||||
requestedChannel === "discord" ||
|
requestedProvider === "discord" ||
|
||||||
requestedChannel === "slack" ||
|
requestedProvider === "slack" ||
|
||||||
requestedChannel === "signal" ||
|
requestedProvider === "signal" ||
|
||||||
requestedChannel === "imessage"
|
requestedProvider === "imessage"
|
||||||
) {
|
) {
|
||||||
return requestedChannel;
|
return requestedProvider;
|
||||||
}
|
}
|
||||||
return lastChannel ?? "whatsapp";
|
return lastProvider ?? "whatsapp";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const to = (() => {
|
const to = (() => {
|
||||||
@ -136,7 +139,7 @@ function resolveDeliveryTarget(
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
const sanitizedWhatsappTo = (() => {
|
const sanitizedWhatsappTo = (() => {
|
||||||
if (channel !== "whatsapp") return to;
|
if (provider !== "whatsapp") return to;
|
||||||
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||||
if (rawAllow.includes("*")) return to;
|
if (rawAllow.includes("*")) return to;
|
||||||
const allowFrom = rawAllow
|
const allowFrom = rawAllow
|
||||||
@ -150,8 +153,8 @@ function resolveDeliveryTarget(
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channel,
|
provider,
|
||||||
to: channel === "whatsapp" ? sanitizedWhatsappTo : to,
|
to: provider === "whatsapp" ? sanitizedWhatsappTo : to,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,7 +184,7 @@ function resolveCronSession(params: {
|
|||||||
model: entry?.model,
|
model: entry?.model,
|
||||||
contextTokens: entry?.contextTokens,
|
contextTokens: entry?.contextTokens,
|
||||||
sendPolicy: entry?.sendPolicy,
|
sendPolicy: entry?.sendPolicy,
|
||||||
lastChannel: entry?.lastChannel,
|
lastProvider: entry?.lastProvider,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
};
|
};
|
||||||
return { storePath, store, sessionEntry, systemSent, isNewSession: !fresh };
|
return { storePath, store, sessionEntry, systemSent, isNewSession: !fresh };
|
||||||
@ -251,9 +254,9 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
params.job.payload.bestEffortDeliver === true;
|
params.job.payload.bestEffortDeliver === true;
|
||||||
|
|
||||||
const resolvedDelivery = resolveDeliveryTarget(params.cfg, {
|
const resolvedDelivery = resolveDeliveryTarget(params.cfg, {
|
||||||
channel:
|
provider:
|
||||||
params.job.payload.kind === "agentTurn"
|
params.job.payload.kind === "agentTurn"
|
||||||
? params.job.payload.channel
|
? params.job.payload.provider
|
||||||
: "last",
|
: "last",
|
||||||
to:
|
to:
|
||||||
params.job.payload.kind === "agentTurn"
|
params.job.payload.kind === "agentTurn"
|
||||||
@ -302,7 +305,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
registerAgentRunContext(cronSession.sessionEntry.sessionId, {
|
registerAgentRunContext(cronSession.sessionEntry.sessionId, {
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
});
|
});
|
||||||
const surface = resolvedDelivery.channel;
|
const messageProvider = resolvedDelivery.provider;
|
||||||
const fallbackResult = await runWithModelFallback({
|
const fallbackResult = await runWithModelFallback({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
provider,
|
provider,
|
||||||
@ -311,7 +314,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
runEmbeddedPiAgent({
|
runEmbeddedPiAgent({
|
||||||
sessionId: cronSession.sessionEntry.sessionId,
|
sessionId: cronSession.sessionEntry.sessionId,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
surface,
|
messageProvider,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
config: params.cfg,
|
config: params.cfg,
|
||||||
@ -380,7 +383,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
delivery && isHeartbeatOnlyResponse(payloads, Math.max(0, ackMaxChars));
|
delivery && isHeartbeatOnlyResponse(payloads, Math.max(0, ackMaxChars));
|
||||||
|
|
||||||
if (delivery && !skipHeartbeatDelivery) {
|
if (delivery && !skipHeartbeatDelivery) {
|
||||||
if (resolvedDelivery.channel === "whatsapp") {
|
if (resolvedDelivery.provider === "whatsapp") {
|
||||||
if (!resolvedDelivery.to) {
|
if (!resolvedDelivery.to) {
|
||||||
if (!bestEffortDeliver)
|
if (!bestEffortDeliver)
|
||||||
return {
|
return {
|
||||||
@ -415,7 +418,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
return { status: "error", summary, error: String(err) };
|
return { status: "error", summary, error: String(err) };
|
||||||
return { status: "ok", summary };
|
return { status: "ok", summary };
|
||||||
}
|
}
|
||||||
} else if (resolvedDelivery.channel === "telegram") {
|
} else if (resolvedDelivery.provider === "telegram") {
|
||||||
if (!resolvedDelivery.to) {
|
if (!resolvedDelivery.to) {
|
||||||
if (!bestEffortDeliver)
|
if (!bestEffortDeliver)
|
||||||
return {
|
return {
|
||||||
@ -459,14 +462,14 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
return { status: "error", summary, error: String(err) };
|
return { status: "error", summary, error: String(err) };
|
||||||
return { status: "ok", summary };
|
return { status: "ok", summary };
|
||||||
}
|
}
|
||||||
} else if (resolvedDelivery.channel === "discord") {
|
} else if (resolvedDelivery.provider === "discord") {
|
||||||
if (!resolvedDelivery.to) {
|
if (!resolvedDelivery.to) {
|
||||||
if (!bestEffortDeliver)
|
if (!bestEffortDeliver)
|
||||||
return {
|
return {
|
||||||
status: "error",
|
status: "error",
|
||||||
summary,
|
summary,
|
||||||
error:
|
error:
|
||||||
"Cron delivery to Discord requires --channel discord and --to <channelId|user:ID>",
|
"Cron delivery to Discord requires --provider discord and --to <channelId|user:ID>",
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
status: "skipped",
|
status: "skipped",
|
||||||
@ -503,14 +506,14 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
return { status: "error", summary, error: String(err) };
|
return { status: "error", summary, error: String(err) };
|
||||||
return { status: "ok", summary };
|
return { status: "ok", summary };
|
||||||
}
|
}
|
||||||
} else if (resolvedDelivery.channel === "slack") {
|
} else if (resolvedDelivery.provider === "slack") {
|
||||||
if (!resolvedDelivery.to) {
|
if (!resolvedDelivery.to) {
|
||||||
if (!bestEffortDeliver)
|
if (!bestEffortDeliver)
|
||||||
return {
|
return {
|
||||||
status: "error",
|
status: "error",
|
||||||
summary,
|
summary,
|
||||||
error:
|
error:
|
||||||
"Cron delivery to Slack requires --channel slack and --to <channelId|user:ID>",
|
"Cron delivery to Slack requires --provider slack and --to <channelId|user:ID>",
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
status: "skipped",
|
status: "skipped",
|
||||||
@ -543,7 +546,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
return { status: "error", summary, error: String(err) };
|
return { status: "error", summary, error: String(err) };
|
||||||
return { status: "ok", summary };
|
return { status: "ok", summary };
|
||||||
}
|
}
|
||||||
} else if (resolvedDelivery.channel === "signal") {
|
} else if (resolvedDelivery.provider === "signal") {
|
||||||
if (!resolvedDelivery.to) {
|
if (!resolvedDelivery.to) {
|
||||||
if (!bestEffortDeliver)
|
if (!bestEffortDeliver)
|
||||||
return {
|
return {
|
||||||
@ -582,7 +585,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
return { status: "error", summary, error: String(err) };
|
return { status: "error", summary, error: String(err) };
|
||||||
return { status: "ok", summary };
|
return { status: "ok", summary };
|
||||||
}
|
}
|
||||||
} else if (resolvedDelivery.channel === "imessage") {
|
} else if (resolvedDelivery.provider === "imessage") {
|
||||||
if (!resolvedDelivery.to) {
|
if (!resolvedDelivery.to) {
|
||||||
if (!bestEffortDeliver)
|
if (!bestEffortDeliver)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export type CronPayload =
|
|||||||
thinking?: string;
|
thinking?: string;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
channel?:
|
provider?:
|
||||||
| "last"
|
| "last"
|
||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
| "telegram"
|
| "telegram"
|
||||||
|
|||||||
@ -31,11 +31,7 @@ import type {
|
|||||||
ReplyToMode,
|
ReplyToMode,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import {
|
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
||||||
resolveSessionKey,
|
|
||||||
resolveStorePath,
|
|
||||||
updateLastRoute,
|
|
||||||
} from "../config/sessions.js";
|
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
@ -45,6 +41,7 @@ import {
|
|||||||
readProviderAllowFromStore,
|
readProviderAllowFromStore,
|
||||||
upsertProviderPairingRequest,
|
upsertProviderPairingRequest,
|
||||||
} from "../pairing/pairing-store.js";
|
} from "../pairing/pairing-store.js";
|
||||||
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { sendMessageDiscord } from "./send.js";
|
import { sendMessageDiscord } from "./send.js";
|
||||||
import { normalizeDiscordToken } from "./token.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);
|
const systemText = resolveDiscordSystemEvent(message);
|
||||||
if (systemText) {
|
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, {
|
enqueueSystemEvent(systemText, {
|
||||||
sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `discord:system:${message.channelId}:${message.id}`,
|
contextKey: `discord:system:${message.channelId}:${message.id}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@ -514,7 +507,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
const groupSubject = isDirectMessage ? undefined : groupRoom;
|
const groupSubject = isDirectMessage ? undefined : groupRoom;
|
||||||
const messageText = text;
|
const messageText = text;
|
||||||
let combinedBody = formatAgentEnvelope({
|
let combinedBody = formatAgentEnvelope({
|
||||||
surface: "Discord",
|
provider: "Discord",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: message.createdTimestamp,
|
timestamp: message.createdTimestamp,
|
||||||
body: messageText,
|
body: messageText,
|
||||||
@ -529,7 +522,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
const historyText = historyWithoutCurrent
|
const historyText = historyWithoutCurrent
|
||||||
.map((entry) =>
|
.map((entry) =>
|
||||||
formatAgentEnvelope({
|
formatAgentEnvelope({
|
||||||
surface: "Discord",
|
provider: "Discord",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
body: `${entry.sender}: ${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`,
|
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}\n[${forwardMetaParts.join(" ")}]`
|
||||||
: snapshotText;
|
: snapshotText;
|
||||||
const forwardedEnvelope = formatAgentEnvelope({
|
const forwardedEnvelope = formatAgentEnvelope({
|
||||||
surface: "Discord",
|
provider: "Discord",
|
||||||
from: `Forwarded by ${forwarder}`,
|
from: `Forwarded by ${forwarder}`,
|
||||||
timestamp:
|
timestamp:
|
||||||
forwardedSnapshot.snapshot.createdTimestamp ??
|
forwardedSnapshot.snapshot.createdTimestamp ??
|
||||||
@ -590,6 +583,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
? `discord:${message.author.id}`
|
? `discord:${message.author.id}`
|
||||||
: `group:${message.channelId}`,
|
: `group:${message.channelId}`,
|
||||||
To: `channel:${message.channelId}`,
|
To: `channel:${message.channelId}`,
|
||||||
|
SessionKey: route.sessionKey,
|
||||||
|
AccountId: route.accountId,
|
||||||
ChatType: isDirectMessage ? "direct" : "group",
|
ChatType: isDirectMessage ? "direct" : "group",
|
||||||
SenderName: message.member?.displayName ?? message.author.tag,
|
SenderName: message.member?.displayName ?? message.author.tag,
|
||||||
SenderId: message.author.id,
|
SenderId: message.author.id,
|
||||||
@ -600,7 +595,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
GroupSpace: isGuildMessage
|
GroupSpace: isGuildMessage
|
||||||
? (guildInfo?.id ?? guildSlug) || undefined
|
? (guildInfo?.id ?? guildSlug) || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
Surface: "discord" as const,
|
Provider: "discord" as const,
|
||||||
WasMentioned: wasMentioned,
|
WasMentioned: wasMentioned,
|
||||||
MessageSid: message.id,
|
MessageSid: message.id,
|
||||||
Timestamp: message.createdTimestamp,
|
Timestamp: message.createdTimestamp,
|
||||||
@ -617,13 +612,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
|
|
||||||
if (isDirectMessage) {
|
if (isDirectMessage) {
|
||||||
const sessionCfg = cfg.session;
|
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({
|
await updateLastRoute({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: mainKey,
|
sessionKey: route.mainSessionKey,
|
||||||
channel: "discord",
|
provider: "discord",
|
||||||
to: `user:${message.author.id}`,
|
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 authorLabel = message.author?.tag ?? message.author?.username;
|
||||||
const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${message.id}`;
|
const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${message.id}`;
|
||||||
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
|
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
|
||||||
const sessionCfg = cfg.session;
|
const route = resolveAgentRoute({
|
||||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
cfg,
|
||||||
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
provider: "discord",
|
||||||
const sessionKey = resolveSessionKey(
|
guildId: guild.id,
|
||||||
sessionScope,
|
peer: { kind: "channel", id: message.channelId },
|
||||||
{
|
});
|
||||||
From: `group:${message.channelId}`,
|
|
||||||
ChatType: "group",
|
|
||||||
Surface: "discord",
|
|
||||||
},
|
|
||||||
mainKey,
|
|
||||||
);
|
|
||||||
enqueueSystemEvent(text, {
|
enqueueSystemEvent(text, {
|
||||||
sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `discord:reaction:${action}:${message.id}:${user.id}:${emojiLabel}`,
|
contextKey: `discord:reaction:${action}:${message.id}:${user.id}:${emojiLabel}`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -884,7 +875,7 @@ async function resolveReplyContext(message: Message): Promise<string | null> {
|
|||||||
: (referenced.member?.displayName ?? referenced.author.tag);
|
: (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}]`;
|
const body = `${referencedText}\n[discord message id: ${referenced.id} channel: ${referenced.channelId} from: ${referenced.author.tag} user id:${referenced.author.id}]`;
|
||||||
return formatAgentEnvelope({
|
return formatAgentEnvelope({
|
||||||
surface: "Discord",
|
provider: "Discord",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: referenced.createdTimestamp,
|
timestamp: referenced.createdTimestamp,
|
||||||
body,
|
body,
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export type HookMappingResolved = {
|
|||||||
messageTemplate?: string;
|
messageTemplate?: string;
|
||||||
textTemplate?: string;
|
textTemplate?: string;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
channel?:
|
provider?:
|
||||||
| "last"
|
| "last"
|
||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
| "telegram"
|
| "telegram"
|
||||||
@ -57,7 +57,7 @@ export type HookAction =
|
|||||||
wakeMode: "now" | "next-heartbeat";
|
wakeMode: "now" | "next-heartbeat";
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
channel?:
|
provider?:
|
||||||
| "last"
|
| "last"
|
||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
| "telegram"
|
| "telegram"
|
||||||
@ -101,7 +101,7 @@ type HookTransformResult = Partial<{
|
|||||||
name: string;
|
name: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
deliver: boolean;
|
deliver: boolean;
|
||||||
channel:
|
provider:
|
||||||
| "last"
|
| "last"
|
||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
| "telegram"
|
| "telegram"
|
||||||
@ -196,7 +196,7 @@ function normalizeHookMapping(
|
|||||||
messageTemplate: mapping.messageTemplate,
|
messageTemplate: mapping.messageTemplate,
|
||||||
textTemplate: mapping.textTemplate,
|
textTemplate: mapping.textTemplate,
|
||||||
deliver: mapping.deliver,
|
deliver: mapping.deliver,
|
||||||
channel: mapping.channel,
|
provider: mapping.provider,
|
||||||
to: mapping.to,
|
to: mapping.to,
|
||||||
thinking: mapping.thinking,
|
thinking: mapping.thinking,
|
||||||
timeoutSeconds: mapping.timeoutSeconds,
|
timeoutSeconds: mapping.timeoutSeconds,
|
||||||
@ -241,7 +241,7 @@ function buildActionFromMapping(
|
|||||||
wakeMode: mapping.wakeMode ?? "now",
|
wakeMode: mapping.wakeMode ?? "now",
|
||||||
sessionKey: renderOptional(mapping.sessionKey, ctx),
|
sessionKey: renderOptional(mapping.sessionKey, ctx),
|
||||||
deliver: mapping.deliver,
|
deliver: mapping.deliver,
|
||||||
channel: mapping.channel,
|
provider: mapping.provider,
|
||||||
to: renderOptional(mapping.to, ctx),
|
to: renderOptional(mapping.to, ctx),
|
||||||
thinking: renderOptional(mapping.thinking, ctx),
|
thinking: renderOptional(mapping.thinking, ctx),
|
||||||
timeoutSeconds: mapping.timeoutSeconds,
|
timeoutSeconds: mapping.timeoutSeconds,
|
||||||
@ -291,7 +291,7 @@ function mergeAction(
|
|||||||
typeof override.deliver === "boolean"
|
typeof override.deliver === "boolean"
|
||||||
? override.deliver
|
? override.deliver
|
||||||
: baseAgent?.deliver,
|
: baseAgent?.deliver,
|
||||||
channel: override.channel ?? baseAgent?.channel,
|
provider: override.provider ?? baseAgent?.provider,
|
||||||
to: override.to ?? baseAgent?.to,
|
to: override.to ?? baseAgent?.to,
|
||||||
thinking: override.thinking ?? baseAgent?.thinking,
|
thinking: override.thinking ?? baseAgent?.thinking,
|
||||||
timeoutSeconds: override.timeoutSeconds ?? baseAgent?.timeoutSeconds,
|
timeoutSeconds: override.timeoutSeconds ?? baseAgent?.timeoutSeconds,
|
||||||
|
|||||||
@ -56,7 +56,7 @@ describe("gateway hooks helpers", () => {
|
|||||||
expect(normalizeWakePayload({ text: " ", mode: "now" }).ok).toBe(false);
|
expect(normalizeWakePayload({ text: " ", mode: "now" }).ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("normalizeAgentPayload defaults + validates channel", () => {
|
test("normalizeAgentPayload defaults + validates provider", () => {
|
||||||
const ok = normalizeAgentPayload(
|
const ok = normalizeAgentPayload(
|
||||||
{ message: "hello" },
|
{ message: "hello" },
|
||||||
{ idFactory: () => "fixed" },
|
{ idFactory: () => "fixed" },
|
||||||
@ -64,20 +64,20 @@ describe("gateway hooks helpers", () => {
|
|||||||
expect(ok.ok).toBe(true);
|
expect(ok.ok).toBe(true);
|
||||||
if (ok.ok) {
|
if (ok.ok) {
|
||||||
expect(ok.value.sessionKey).toBe("hook:fixed");
|
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");
|
expect(ok.value.name).toBe("Hook");
|
||||||
}
|
}
|
||||||
|
|
||||||
const imsg = normalizeAgentPayload(
|
const imsg = normalizeAgentPayload(
|
||||||
{ message: "yo", channel: "imsg" },
|
{ message: "yo", provider: "imsg" },
|
||||||
{ idFactory: () => "x" },
|
{ idFactory: () => "x" },
|
||||||
);
|
);
|
||||||
expect(imsg.ok).toBe(true);
|
expect(imsg.ok).toBe(true);
|
||||||
if (imsg.ok) {
|
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);
|
expect(bad.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -137,7 +137,7 @@ export type HookAgentPayload = {
|
|||||||
wakeMode: "now" | "next-heartbeat";
|
wakeMode: "now" | "next-heartbeat";
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
deliver: boolean;
|
deliver: boolean;
|
||||||
channel:
|
provider:
|
||||||
| "last"
|
| "last"
|
||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
| "telegram"
|
| "telegram"
|
||||||
@ -173,26 +173,26 @@ export function normalizeAgentPayload(
|
|||||||
typeof sessionKeyRaw === "string" && sessionKeyRaw.trim()
|
typeof sessionKeyRaw === "string" && sessionKeyRaw.trim()
|
||||||
? sessionKeyRaw.trim()
|
? sessionKeyRaw.trim()
|
||||||
: `hook:${idFactory()}`;
|
: `hook:${idFactory()}`;
|
||||||
const channelRaw = payload.channel;
|
const providerRaw = payload.provider;
|
||||||
const channel =
|
const provider =
|
||||||
channelRaw === "whatsapp" ||
|
providerRaw === "whatsapp" ||
|
||||||
channelRaw === "telegram" ||
|
providerRaw === "telegram" ||
|
||||||
channelRaw === "discord" ||
|
providerRaw === "discord" ||
|
||||||
channelRaw === "slack" ||
|
providerRaw === "slack" ||
|
||||||
channelRaw === "signal" ||
|
providerRaw === "signal" ||
|
||||||
channelRaw === "imessage" ||
|
providerRaw === "imessage" ||
|
||||||
channelRaw === "last"
|
providerRaw === "last"
|
||||||
? channelRaw
|
? providerRaw
|
||||||
: channelRaw === "imsg"
|
: providerRaw === "imsg"
|
||||||
? "imessage"
|
? "imessage"
|
||||||
: channelRaw === undefined
|
: providerRaw === undefined
|
||||||
? "last"
|
? "last"
|
||||||
: null;
|
: null;
|
||||||
if (channel === null) {
|
if (provider === null) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error:
|
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;
|
const toRaw = payload.to;
|
||||||
@ -219,7 +219,7 @@ export function normalizeAgentPayload(
|
|||||||
wakeMode,
|
wakeMode,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
deliver,
|
deliver,
|
||||||
channel,
|
provider,
|
||||||
to,
|
to,
|
||||||
thinking,
|
thinking,
|
||||||
timeoutSeconds,
|
timeoutSeconds,
|
||||||
|
|||||||
@ -193,6 +193,7 @@ export const SendParamsSchema = Type.Object(
|
|||||||
mediaUrl: Type.Optional(Type.String()),
|
mediaUrl: Type.Optional(Type.String()),
|
||||||
gifPlayback: Type.Optional(Type.Boolean()),
|
gifPlayback: Type.Optional(Type.Boolean()),
|
||||||
provider: Type.Optional(Type.String()),
|
provider: Type.Optional(Type.String()),
|
||||||
|
accountId: Type.Optional(Type.String()),
|
||||||
idempotencyKey: NonEmptyString,
|
idempotencyKey: NonEmptyString,
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
@ -206,6 +207,7 @@ export const PollParamsSchema = Type.Object(
|
|||||||
maxSelections: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })),
|
maxSelections: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })),
|
||||||
durationHours: Type.Optional(Type.Integer({ minimum: 1 })),
|
durationHours: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
provider: Type.Optional(Type.String()),
|
provider: Type.Optional(Type.String()),
|
||||||
|
accountId: Type.Optional(Type.String()),
|
||||||
idempotencyKey: NonEmptyString,
|
idempotencyKey: NonEmptyString,
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
@ -218,7 +220,7 @@ export const AgentParamsSchema = Type.Object(
|
|||||||
sessionKey: Type.Optional(Type.String()),
|
sessionKey: Type.Optional(Type.String()),
|
||||||
thinking: Type.Optional(Type.String()),
|
thinking: Type.Optional(Type.String()),
|
||||||
deliver: Type.Optional(Type.Boolean()),
|
deliver: Type.Optional(Type.Boolean()),
|
||||||
channel: Type.Optional(Type.String()),
|
provider: Type.Optional(Type.String()),
|
||||||
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
|
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
lane: Type.Optional(Type.String()),
|
lane: Type.Optional(Type.String()),
|
||||||
extraSystemPrompt: Type.Optional(Type.String()),
|
extraSystemPrompt: Type.Optional(Type.String()),
|
||||||
@ -543,6 +545,7 @@ export const WebLoginStartParamsSchema = Type.Object(
|
|||||||
force: Type.Optional(Type.Boolean()),
|
force: Type.Optional(Type.Boolean()),
|
||||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
verbose: Type.Optional(Type.Boolean()),
|
verbose: Type.Optional(Type.Boolean()),
|
||||||
|
accountId: Type.Optional(Type.String()),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
@ -550,6 +553,7 @@ export const WebLoginStartParamsSchema = Type.Object(
|
|||||||
export const WebLoginWaitParamsSchema = Type.Object(
|
export const WebLoginWaitParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
|
accountId: Type.Optional(Type.String()),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
@ -642,7 +646,7 @@ export const CronPayloadSchema = Type.Union([
|
|||||||
thinking: Type.Optional(Type.String()),
|
thinking: Type.Optional(Type.String()),
|
||||||
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
|
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
deliver: Type.Optional(Type.Boolean()),
|
deliver: Type.Optional(Type.Boolean()),
|
||||||
channel: Type.Optional(
|
provider: Type.Optional(
|
||||||
Type.Union([
|
Type.Union([
|
||||||
Type.Literal("last"),
|
Type.Literal("last"),
|
||||||
Type.Literal("whatsapp"),
|
Type.Literal("whatsapp"),
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import {
|
|||||||
setVoiceWakeTriggers,
|
setVoiceWakeTriggers,
|
||||||
} from "../infra/voicewake.js";
|
} from "../infra/voicewake.js";
|
||||||
import { clearCommandLane } from "../process/command-queue.js";
|
import { clearCommandLane } from "../process/command-queue.js";
|
||||||
|
import { isSubagentSessionKey } from "../routing/session-key.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
||||||
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
||||||
@ -372,7 +373,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!key.startsWith("subagent:")) {
|
if (!isSubagentSessionKey(key)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: {
|
error: {
|
||||||
@ -606,11 +607,11 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
sendPolicy: entry?.sendPolicy,
|
sendPolicy: entry?.sendPolicy,
|
||||||
displayName: entry?.displayName,
|
displayName: entry?.displayName,
|
||||||
chatType: entry?.chatType,
|
chatType: entry?.chatType,
|
||||||
surface: entry?.surface,
|
provider: entry?.provider,
|
||||||
subject: entry?.subject,
|
subject: entry?.subject,
|
||||||
room: entry?.room,
|
room: entry?.room,
|
||||||
space: entry?.space,
|
space: entry?.space,
|
||||||
lastChannel: entry?.lastChannel,
|
lastProvider: entry?.lastProvider,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
skillsSnapshot: entry?.skillsSnapshot,
|
skillsSnapshot: entry?.skillsSnapshot,
|
||||||
};
|
};
|
||||||
@ -986,7 +987,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
thinkingLevel: entry?.thinkingLevel,
|
thinkingLevel: entry?.thinkingLevel,
|
||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
systemSent: entry?.systemSent,
|
systemSent: entry?.systemSent,
|
||||||
lastChannel: entry?.lastChannel,
|
lastProvider: entry?.lastProvider,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
};
|
};
|
||||||
const clientRunId = p.idempotencyKey;
|
const clientRunId = p.idempotencyKey;
|
||||||
@ -1033,7 +1034,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
thinking: p.thinking,
|
thinking: p.thinking,
|
||||||
deliver: p.deliver,
|
deliver: p.deliver,
|
||||||
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
||||||
surface: `Node(${nodeId})`,
|
messageProvider: `node(${nodeId})`,
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
},
|
},
|
||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
@ -1126,7 +1127,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
systemSent: entry?.systemSent,
|
systemSent: entry?.systemSent,
|
||||||
sendPolicy: entry?.sendPolicy,
|
sendPolicy: entry?.sendPolicy,
|
||||||
lastChannel: entry?.lastChannel,
|
lastProvider: entry?.lastProvider,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
};
|
};
|
||||||
if (storePath) {
|
if (storePath) {
|
||||||
@ -1146,7 +1147,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
sessionId,
|
sessionId,
|
||||||
thinking: "low",
|
thinking: "low",
|
||||||
deliver: false,
|
deliver: false,
|
||||||
surface: "Node",
|
messageProvider: "node",
|
||||||
},
|
},
|
||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
ctx.deps,
|
ctx.deps,
|
||||||
@ -1208,7 +1209,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
systemSent: entry?.systemSent,
|
systemSent: entry?.systemSent,
|
||||||
sendPolicy: entry?.sendPolicy,
|
sendPolicy: entry?.sendPolicy,
|
||||||
lastChannel: entry?.lastChannel,
|
lastProvider: entry?.lastProvider,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
};
|
};
|
||||||
if (storePath) {
|
if (storePath) {
|
||||||
@ -1227,7 +1228,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
typeof link?.timeoutSeconds === "number"
|
typeof link?.timeoutSeconds === "number"
|
||||||
? link.timeoutSeconds.toString()
|
? link.timeoutSeconds.toString()
|
||||||
: undefined,
|
: undefined,
|
||||||
surface: "Node",
|
messageProvider: "node",
|
||||||
},
|
},
|
||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
ctx.deps,
|
ctx.deps,
|
||||||
|
|||||||
@ -32,7 +32,7 @@ type HookDispatchers = {
|
|||||||
wakeMode: "now" | "next-heartbeat";
|
wakeMode: "now" | "next-heartbeat";
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
deliver: boolean;
|
deliver: boolean;
|
||||||
channel:
|
provider:
|
||||||
| "last"
|
| "last"
|
||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
| "telegram"
|
| "telegram"
|
||||||
@ -175,7 +175,7 @@ export function createHooksRequestHandler(
|
|||||||
wakeMode: mapped.action.wakeMode,
|
wakeMode: mapped.action.wakeMode,
|
||||||
sessionKey: mapped.action.sessionKey ?? "",
|
sessionKey: mapped.action.sessionKey ?? "",
|
||||||
deliver: mapped.action.deliver === true,
|
deliver: mapped.action.deliver === true,
|
||||||
channel: mapped.action.channel ?? "last",
|
provider: mapped.action.provider ?? "last",
|
||||||
to: mapped.action.to,
|
to: mapped.action.to,
|
||||||
thinking: mapped.action.thinking,
|
thinking: mapped.action.thinking,
|
||||||
timeoutSeconds: mapped.action.timeoutSeconds,
|
timeoutSeconds: mapped.action.timeoutSeconds,
|
||||||
|
|||||||
@ -2,7 +2,12 @@ import { randomUUID } from "node:crypto";
|
|||||||
|
|
||||||
import { agentCommand } from "../../commands/agent.js";
|
import { agentCommand } from "../../commands/agent.js";
|
||||||
import { loadConfig } from "../../config/config.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 { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||||
@ -41,7 +46,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
channel?: string;
|
provider?: string;
|
||||||
lane?: string;
|
lane?: string;
|
||||||
extraSystemPrompt?: string;
|
extraSystemPrompt?: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
@ -72,7 +77,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
cfgForAgent = cfg;
|
cfgForAgent = cfg;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const sessionId = entry?.sessionId ?? randomUUID();
|
const sessionId = entry?.sessionId ?? randomUUID();
|
||||||
sessionEntry = {
|
const nextEntry: SessionEntry = {
|
||||||
sessionId,
|
sessionId,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
thinkingLevel: entry?.thinkingLevel,
|
thinkingLevel: entry?.thinkingLevel,
|
||||||
@ -80,14 +85,15 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
systemSent: entry?.systemSent,
|
systemSent: entry?.systemSent,
|
||||||
sendPolicy: entry?.sendPolicy,
|
sendPolicy: entry?.sendPolicy,
|
||||||
skillsSnapshot: entry?.skillsSnapshot,
|
skillsSnapshot: entry?.skillsSnapshot,
|
||||||
lastChannel: entry?.lastChannel,
|
lastProvider: entry?.lastProvider,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
};
|
};
|
||||||
|
sessionEntry = nextEntry;
|
||||||
const sendPolicy = resolveSendPolicy({
|
const sendPolicy = resolveSendPolicy({
|
||||||
cfg,
|
cfg,
|
||||||
entry,
|
entry,
|
||||||
sessionKey: requestedSessionKey,
|
sessionKey: requestedSessionKey,
|
||||||
surface: entry?.surface,
|
provider: entry?.provider,
|
||||||
chatType: entry?.chatType,
|
chatType: entry?.chatType,
|
||||||
});
|
});
|
||||||
if (sendPolicy === "deny") {
|
if (sendPolicy === "deny") {
|
||||||
@ -102,14 +108,22 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (store) {
|
if (store) {
|
||||||
store[requestedSessionKey] = sessionEntry;
|
store[requestedSessionKey] = nextEntry;
|
||||||
if (storePath) {
|
if (storePath) {
|
||||||
await saveSessionStore(storePath, store);
|
await saveSessionStore(storePath, store);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resolvedSessionId = sessionId;
|
resolvedSessionId = sessionId;
|
||||||
const mainKey = (cfg.session?.mainKey ?? "main").trim() || "main";
|
const agentId = resolveAgentIdFromSessionKey(requestedSessionKey);
|
||||||
if (requestedSessionKey === mainKey) {
|
const mainSessionKey = resolveAgentMainSessionKey({
|
||||||
|
cfg,
|
||||||
|
agentId,
|
||||||
|
});
|
||||||
|
const rawMainKey = (cfg.session?.mainKey ?? "main").trim() || "main";
|
||||||
|
if (
|
||||||
|
requestedSessionKey === mainSessionKey ||
|
||||||
|
requestedSessionKey === rawMainKey
|
||||||
|
) {
|
||||||
context.addChatRun(idem, {
|
context.addChatRun(idem, {
|
||||||
sessionKey: requestedSessionKey,
|
sessionKey: requestedSessionKey,
|
||||||
clientRunId: idem,
|
clientRunId: idem,
|
||||||
@ -121,42 +135,42 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
|
|
||||||
const runId = idem;
|
const runId = idem;
|
||||||
|
|
||||||
const requestedChannelRaw =
|
const requestedProviderRaw =
|
||||||
typeof request.channel === "string" ? request.channel.trim() : "";
|
typeof request.provider === "string" ? request.provider.trim() : "";
|
||||||
const requestedChannelNormalized = requestedChannelRaw
|
const requestedProviderNormalized = requestedProviderRaw
|
||||||
? requestedChannelRaw.toLowerCase()
|
? requestedProviderRaw.toLowerCase()
|
||||||
: "last";
|
: "last";
|
||||||
const requestedChannel =
|
const requestedProvider =
|
||||||
requestedChannelNormalized === "imsg"
|
requestedProviderNormalized === "imsg"
|
||||||
? "imessage"
|
? "imessage"
|
||||||
: requestedChannelNormalized;
|
: requestedProviderNormalized;
|
||||||
|
|
||||||
const lastChannel = sessionEntry?.lastChannel;
|
const lastProvider = sessionEntry?.lastProvider;
|
||||||
const lastTo =
|
const lastTo =
|
||||||
typeof sessionEntry?.lastTo === "string"
|
typeof sessionEntry?.lastTo === "string"
|
||||||
? sessionEntry.lastTo.trim()
|
? sessionEntry.lastTo.trim()
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const resolvedChannel = (() => {
|
const resolvedProvider = (() => {
|
||||||
if (requestedChannel === "last") {
|
if (requestedProvider === "last") {
|
||||||
// WebChat is not a deliverable surface. Treat it as "unset" for routing,
|
// WebChat is not a deliverable surface. Treat it as "unset" for routing,
|
||||||
// so VoiceWake and CLI callers don't get stuck with deliver=false.
|
// so VoiceWake and CLI callers don't get stuck with deliver=false.
|
||||||
return lastChannel && lastChannel !== "webchat"
|
return lastProvider && lastProvider !== "webchat"
|
||||||
? lastChannel
|
? lastProvider
|
||||||
: "whatsapp";
|
: "whatsapp";
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
requestedChannel === "whatsapp" ||
|
requestedProvider === "whatsapp" ||
|
||||||
requestedChannel === "telegram" ||
|
requestedProvider === "telegram" ||
|
||||||
requestedChannel === "discord" ||
|
requestedProvider === "discord" ||
|
||||||
requestedChannel === "signal" ||
|
requestedProvider === "signal" ||
|
||||||
requestedChannel === "imessage" ||
|
requestedProvider === "imessage" ||
|
||||||
requestedChannel === "webchat"
|
requestedProvider === "webchat"
|
||||||
) {
|
) {
|
||||||
return requestedChannel;
|
return requestedProvider;
|
||||||
}
|
}
|
||||||
return lastChannel && lastChannel !== "webchat"
|
return lastProvider && lastProvider !== "webchat"
|
||||||
? lastChannel
|
? lastProvider
|
||||||
: "whatsapp";
|
: "whatsapp";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@ -167,11 +181,11 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
: undefined;
|
: undefined;
|
||||||
if (explicit) return explicit;
|
if (explicit) return explicit;
|
||||||
if (
|
if (
|
||||||
resolvedChannel === "whatsapp" ||
|
resolvedProvider === "whatsapp" ||
|
||||||
resolvedChannel === "telegram" ||
|
resolvedProvider === "telegram" ||
|
||||||
resolvedChannel === "discord" ||
|
resolvedProvider === "discord" ||
|
||||||
resolvedChannel === "signal" ||
|
resolvedProvider === "signal" ||
|
||||||
resolvedChannel === "imessage"
|
resolvedProvider === "imessage"
|
||||||
) {
|
) {
|
||||||
return lastTo || undefined;
|
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
|
// 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
|
// for the configured allowlist. Otherwise, fall back to the first allowed number so
|
||||||
// voice wake doesn't silently route to stale/test recipients.
|
// voice wake doesn't silently route to stale/test recipients.
|
||||||
if (resolvedChannel !== "whatsapp") return resolvedTo;
|
if (resolvedProvider !== "whatsapp") return resolvedTo;
|
||||||
const explicit =
|
const explicit =
|
||||||
typeof request.to === "string" && request.to.trim()
|
typeof request.to === "string" && request.to.trim()
|
||||||
? request.to.trim()
|
? request.to.trim()
|
||||||
@ -207,7 +221,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
return allowFrom[0];
|
return allowFrom[0];
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const deliver = request.deliver === true && resolvedChannel !== "webchat";
|
const deliver = request.deliver === true && resolvedProvider !== "webchat";
|
||||||
|
|
||||||
const accepted = {
|
const accepted = {
|
||||||
runId,
|
runId,
|
||||||
@ -229,10 +243,10 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
sessionId: resolvedSessionId,
|
sessionId: resolvedSessionId,
|
||||||
thinking: request.thinking,
|
thinking: request.thinking,
|
||||||
deliver,
|
deliver,
|
||||||
provider: resolvedChannel,
|
provider: resolvedProvider,
|
||||||
timeout: request.timeout?.toString(),
|
timeout: request.timeout?.toString(),
|
||||||
bestEffortDeliver,
|
bestEffortDeliver,
|
||||||
surface: "VoiceWake",
|
messageProvider: "voicewake",
|
||||||
runId,
|
runId,
|
||||||
lane: request.lane,
|
lane: request.lane,
|
||||||
extraSystemPrompt: request.extraSystemPrompt,
|
extraSystemPrompt: request.extraSystemPrompt,
|
||||||
|
|||||||
@ -202,7 +202,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
systemSent: entry?.systemSent,
|
systemSent: entry?.systemSent,
|
||||||
sendPolicy: entry?.sendPolicy,
|
sendPolicy: entry?.sendPolicy,
|
||||||
lastChannel: entry?.lastChannel,
|
lastProvider: entry?.lastProvider,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
};
|
};
|
||||||
const clientRunId = p.idempotencyKey;
|
const clientRunId = p.idempotencyKey;
|
||||||
@ -212,7 +212,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
cfg,
|
cfg,
|
||||||
entry,
|
entry,
|
||||||
sessionKey: p.sessionKey,
|
sessionKey: p.sessionKey,
|
||||||
surface: entry?.surface,
|
provider: entry?.provider,
|
||||||
chatType: entry?.chatType,
|
chatType: entry?.chatType,
|
||||||
});
|
});
|
||||||
if (sendPolicy === "deny") {
|
if (sendPolicy === "deny") {
|
||||||
@ -262,7 +262,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
thinking: p.thinking,
|
thinking: p.thinking,
|
||||||
deliver: p.deliver,
|
deliver: p.deliver,
|
||||||
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
||||||
surface: "WebChat",
|
messageProvider: "webchat",
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
},
|
},
|
||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
} from "../../config/config.js";
|
} from "../../config/config.js";
|
||||||
import { type DiscordProbe, probeDiscord } from "../../discord/probe.js";
|
import { type DiscordProbe, probeDiscord } from "../../discord/probe.js";
|
||||||
import { type IMessageProbe, probeIMessage } from "../../imessage/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 { probeSignal, type SignalProbe } from "../../signal/probe.js";
|
||||||
import { probeSlack, type SlackProbe } from "../../slack/probe.js";
|
import { probeSlack, type SlackProbe } from "../../slack/probe.js";
|
||||||
import {
|
import {
|
||||||
@ -15,7 +14,15 @@ import {
|
|||||||
} from "../../slack/token.js";
|
} from "../../slack/token.js";
|
||||||
import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js";
|
import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js";
|
||||||
import { resolveTelegramToken } from "../../telegram/token.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 {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
errorShape,
|
errorShape,
|
||||||
@ -148,10 +155,55 @@ export const providersHandlers: GatewayRequestHandlers = {
|
|||||||
imessageLastProbeAt = Date.now();
|
imessageLastProbeAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
const linked = await webAuthExists();
|
|
||||||
const authAgeMs = getWebAuthAgeMs();
|
|
||||||
const self = readWebSelfId();
|
|
||||||
const runtime = context.getRuntimeSnapshot();
|
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(
|
respond(
|
||||||
true,
|
true,
|
||||||
@ -171,6 +223,8 @@ export const providersHandlers: GatewayRequestHandlers = {
|
|||||||
lastEventAt: runtime.whatsapp.lastEventAt ?? null,
|
lastEventAt: runtime.whatsapp.lastEventAt ?? null,
|
||||||
lastError: runtime.whatsapp.lastError ?? null,
|
lastError: runtime.whatsapp.lastError ?? null,
|
||||||
},
|
},
|
||||||
|
whatsappAccounts,
|
||||||
|
whatsappDefaultAccountId: defaultWhatsAppAccountId,
|
||||||
telegram: {
|
telegram: {
|
||||||
configured: telegramEnabled && Boolean(telegramToken),
|
configured: telegramEnabled && Boolean(telegramToken),
|
||||||
tokenSource,
|
tokenSource,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { sendMessageSignal } from "../../signal/index.js";
|
|||||||
import { sendMessageSlack } from "../../slack/send.js";
|
import { sendMessageSlack } from "../../slack/send.js";
|
||||||
import { sendMessageTelegram } from "../../telegram/send.js";
|
import { sendMessageTelegram } from "../../telegram/send.js";
|
||||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
import { resolveTelegramToken } from "../../telegram/token.js";
|
||||||
|
import { resolveDefaultWhatsAppAccountId } from "../../web/accounts.js";
|
||||||
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
|
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
|
||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
@ -37,6 +38,7 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
gifPlayback?: boolean;
|
gifPlayback?: boolean;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
|
accountId?: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
};
|
};
|
||||||
const idem = request.idempotencyKey;
|
const idem = request.idempotencyKey;
|
||||||
@ -148,10 +150,17 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
});
|
});
|
||||||
respond(true, payload, undefined, { provider });
|
respond(true, payload, undefined, { provider });
|
||||||
} else {
|
} 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, {
|
const result = await sendMessageWhatsApp(to, message, {
|
||||||
mediaUrl: request.mediaUrl,
|
mediaUrl: request.mediaUrl,
|
||||||
verbose: shouldLogVerbose(),
|
verbose: shouldLogVerbose(),
|
||||||
gifPlayback: request.gifPlayback,
|
gifPlayback: request.gifPlayback,
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
const payload = {
|
const payload = {
|
||||||
runId: idem,
|
runId: idem,
|
||||||
@ -199,6 +208,7 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
maxSelections?: number;
|
maxSelections?: number;
|
||||||
durationHours?: number;
|
durationHours?: number;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
|
accountId?: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
};
|
};
|
||||||
const idem = request.idempotencyKey;
|
const idem = request.idempotencyKey;
|
||||||
@ -245,8 +255,15 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
});
|
});
|
||||||
respond(true, payload, undefined, { provider });
|
respond(true, payload, undefined, { provider });
|
||||||
} else {
|
} 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, {
|
const result = await sendPollWhatsApp(to, poll, {
|
||||||
verbose: shouldLogVerbose(),
|
verbose: shouldLogVerbose(),
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
const payload = {
|
const payload = {
|
||||||
runId: idem,
|
runId: idem,
|
||||||
|
|||||||
@ -24,11 +24,11 @@ import { loadConfig } from "../../config/config.js";
|
|||||||
import {
|
import {
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveMainSessionKey,
|
resolveMainSessionKey,
|
||||||
resolveStorePath,
|
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
import { clearCommandLane } from "../../process/command-queue.js";
|
import { clearCommandLane } from "../../process/command-queue.js";
|
||||||
|
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
||||||
import { normalizeSendPolicy } from "../../sessions/send-policy.js";
|
import { normalizeSendPolicy } from "../../sessions/send-policy.js";
|
||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
@ -43,7 +43,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
archiveFileOnDisk,
|
archiveFileOnDisk,
|
||||||
listSessionsFromStore,
|
listSessionsFromStore,
|
||||||
loadSessionEntry,
|
loadCombinedSessionStoreForGateway,
|
||||||
|
resolveGatewaySessionStoreTarget,
|
||||||
resolveSessionTranscriptCandidates,
|
resolveSessionTranscriptCandidates,
|
||||||
type SessionsPatchResult,
|
type SessionsPatchResult,
|
||||||
} from "../session-utils.js";
|
} from "../session-utils.js";
|
||||||
@ -64,8 +65,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
const p = params as import("../protocol/index.js").SessionsListParams;
|
const p = params as import("../protocol/index.js").SessionsListParams;
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const storePath = resolveStorePath(cfg.session?.store);
|
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
|
||||||
const store = loadSessionStore(storePath);
|
|
||||||
const result = listSessionsFromStore({
|
const result = listSessionsFromStore({
|
||||||
cfg,
|
cfg,
|
||||||
storePath,
|
storePath,
|
||||||
@ -98,11 +98,18 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const storePath = resolveStorePath(cfg.session?.store);
|
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||||
|
const storePath = target.storePath;
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
const now = Date.now();
|
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
|
const next: SessionEntry = existing
|
||||||
? {
|
? {
|
||||||
...existing,
|
...existing,
|
||||||
@ -134,7 +141,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!key.startsWith("subagent:")) {
|
if (!isSubagentSessionKey(primaryKey)) {
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
undefined,
|
undefined,
|
||||||
@ -311,12 +318,12 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
store[key] = next;
|
store[primaryKey] = next;
|
||||||
await saveSessionStore(storePath, store);
|
await saveSessionStore(storePath, store);
|
||||||
const result: SessionsPatchResult = {
|
const result: SessionsPatchResult = {
|
||||||
ok: true,
|
ok: true,
|
||||||
path: storePath,
|
path: storePath,
|
||||||
key,
|
key: target.canonicalKey,
|
||||||
entry: next,
|
entry: next,
|
||||||
};
|
};
|
||||||
respond(true, result, undefined);
|
respond(true, result, undefined);
|
||||||
@ -344,7 +351,17 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
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 now = Date.now();
|
||||||
const next: SessionEntry = {
|
const next: SessionEntry = {
|
||||||
sessionId: randomUUID(),
|
sessionId: randomUUID(),
|
||||||
@ -356,13 +373,17 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
model: entry?.model,
|
model: entry?.model,
|
||||||
contextTokens: entry?.contextTokens,
|
contextTokens: entry?.contextTokens,
|
||||||
sendPolicy: entry?.sendPolicy,
|
sendPolicy: entry?.sendPolicy,
|
||||||
lastChannel: entry?.lastChannel,
|
lastProvider: entry?.lastProvider,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
skillsSnapshot: entry?.skillsSnapshot,
|
skillsSnapshot: entry?.skillsSnapshot,
|
||||||
};
|
};
|
||||||
store[key] = next;
|
store[primaryKey] = next;
|
||||||
await saveSessionStore(storePath, store);
|
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 }) => {
|
"sessions.delete": async ({ params, respond }) => {
|
||||||
if (!validateSessionsDeleteParams(params)) {
|
if (!validateSessionsDeleteParams(params)) {
|
||||||
@ -387,8 +408,10 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainKey = resolveMainSessionKey(loadConfig());
|
const cfg = loadConfig();
|
||||||
if (key === mainKey) {
|
const mainKey = resolveMainSessionKey(cfg);
|
||||||
|
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||||
|
if (target.canonicalKey === mainKey) {
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
undefined,
|
undefined,
|
||||||
@ -403,10 +426,18 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
const deleteTranscript =
|
const deleteTranscript =
|
||||||
typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
|
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 sessionId = entry?.sessionId;
|
||||||
const existed = Boolean(store[key]);
|
const existed = Boolean(entry);
|
||||||
clearCommandLane(resolveEmbeddedSessionLane(key));
|
clearCommandLane(resolveEmbeddedSessionLane(target.canonicalKey));
|
||||||
if (sessionId && isEmbeddedPiRunActive(sessionId)) {
|
if (sessionId && isEmbeddedPiRunActive(sessionId)) {
|
||||||
abortEmbeddedPiRun(sessionId);
|
abortEmbeddedPiRun(sessionId);
|
||||||
const ended = await waitForEmbeddedPiRunEnd(sessionId, 15_000);
|
const ended = await waitForEmbeddedPiRunEnd(sessionId, 15_000);
|
||||||
@ -422,7 +453,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (existed) delete store[key];
|
if (existed) delete store[primaryKey];
|
||||||
await saveSessionStore(storePath, store);
|
await saveSessionStore(storePath, store);
|
||||||
|
|
||||||
const archived: string[] = [];
|
const archived: string[] = [];
|
||||||
@ -430,6 +461,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
for (const candidate of resolveSessionTranscriptCandidates(
|
for (const candidate of resolveSessionTranscriptCandidates(
|
||||||
sessionId,
|
sessionId,
|
||||||
storePath,
|
storePath,
|
||||||
|
target.agentId,
|
||||||
)) {
|
)) {
|
||||||
if (!fs.existsSync(candidate)) continue;
|
if (!fs.existsSync(candidate)) continue;
|
||||||
try {
|
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 }) => {
|
"sessions.compact": async ({ params, respond }) => {
|
||||||
if (!validateSessionsCompactParams(params)) {
|
if (!validateSessionsCompactParams(params)) {
|
||||||
@ -470,12 +506,27 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
? Math.max(1, Math.floor(p.maxLines))
|
? Math.max(1, Math.floor(p.maxLines))
|
||||||
: 400;
|
: 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;
|
const sessionId = entry?.sessionId;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
respond(
|
respond(
|
||||||
true,
|
true,
|
||||||
{ ok: true, key, compacted: false, reason: "no sessionId" },
|
{
|
||||||
|
ok: true,
|
||||||
|
key: target.canonicalKey,
|
||||||
|
compacted: false,
|
||||||
|
reason: "no sessionId",
|
||||||
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@ -484,11 +535,17 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
const filePath = resolveSessionTranscriptCandidates(
|
const filePath = resolveSessionTranscriptCandidates(
|
||||||
sessionId,
|
sessionId,
|
||||||
storePath,
|
storePath,
|
||||||
|
target.agentId,
|
||||||
).find((candidate) => fs.existsSync(candidate));
|
).find((candidate) => fs.existsSync(candidate));
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
respond(
|
respond(
|
||||||
true,
|
true,
|
||||||
{ ok: true, key, compacted: false, reason: "no transcript" },
|
{
|
||||||
|
ok: true,
|
||||||
|
key: target.canonicalKey,
|
||||||
|
compacted: false,
|
||||||
|
reason: "no transcript",
|
||||||
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@ -499,7 +556,12 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
if (lines.length <= maxLines) {
|
if (lines.length <= maxLines) {
|
||||||
respond(
|
respond(
|
||||||
true,
|
true,
|
||||||
{ ok: true, key, compacted: false, kept: lines.length },
|
{
|
||||||
|
ok: true,
|
||||||
|
key: target.canonicalKey,
|
||||||
|
compacted: false,
|
||||||
|
kept: lines.length,
|
||||||
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@ -509,11 +571,11 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
const keptLines = lines.slice(-maxLines);
|
const keptLines = lines.slice(-maxLines);
|
||||||
fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8");
|
fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8");
|
||||||
|
|
||||||
if (store[key]) {
|
if (store[primaryKey]) {
|
||||||
delete store[key].inputTokens;
|
delete store[primaryKey].inputTokens;
|
||||||
delete store[key].outputTokens;
|
delete store[primaryKey].outputTokens;
|
||||||
delete store[key].totalTokens;
|
delete store[primaryKey].totalTokens;
|
||||||
store[key].updatedAt = Date.now();
|
store[primaryKey].updatedAt = Date.now();
|
||||||
await saveSessionStore(storePath, store);
|
await saveSessionStore(storePath, store);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -521,7 +583,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
key,
|
key: target.canonicalKey,
|
||||||
compacted: true,
|
compacted: true,
|
||||||
archived,
|
archived,
|
||||||
kept: keptLines.length,
|
kept: keptLines.length,
|
||||||
|
|||||||
@ -69,10 +69,10 @@ export type GatewayRequestContext = {
|
|||||||
findRunningWizard: () => string | null;
|
findRunningWizard: () => string | null;
|
||||||
purgeWizardSession: (id: string) => void;
|
purgeWizardSession: (id: string) => void;
|
||||||
getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
|
getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
|
||||||
startWhatsAppProvider: () => Promise<void>;
|
startWhatsAppProvider: (accountId?: string) => Promise<void>;
|
||||||
stopWhatsAppProvider: () => Promise<void>;
|
stopWhatsAppProvider: (accountId?: string) => Promise<void>;
|
||||||
stopTelegramProvider: () => Promise<void>;
|
stopTelegramProvider: () => Promise<void>;
|
||||||
markWhatsAppLoggedOut: (cleared: boolean) => void;
|
markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void;
|
||||||
wizardRunner: (
|
wizardRunner: (
|
||||||
opts: import("../../commands/onboard-types.js").OnboardOptions,
|
opts: import("../../commands/onboard-types.js").OnboardOptions,
|
||||||
runtime: import("../../runtime.js").RuntimeEnv,
|
runtime: import("../../runtime.js").RuntimeEnv,
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
|
import { resolveWhatsAppAccount } from "../../web/accounts.js";
|
||||||
import { startWebLoginWithQr, waitForWebLogin } from "../../web/login-qr.js";
|
import { startWebLoginWithQr, waitForWebLogin } from "../../web/login-qr.js";
|
||||||
import { logoutWeb } from "../../web/session.js";
|
import { logoutWeb } from "../../web/session.js";
|
||||||
import {
|
import {
|
||||||
@ -25,7 +27,11 @@ export const webHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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({
|
const result = await startWebLoginWithQr({
|
||||||
force: Boolean((params as { force?: boolean }).force),
|
force: Boolean((params as { force?: boolean }).force),
|
||||||
timeoutMs:
|
timeoutMs:
|
||||||
@ -33,6 +39,7 @@ export const webHandlers: GatewayRequestHandlers = {
|
|||||||
? (params as { timeoutMs?: number }).timeoutMs
|
? (params as { timeoutMs?: number }).timeoutMs
|
||||||
: undefined,
|
: undefined,
|
||||||
verbose: Boolean((params as { verbose?: boolean }).verbose),
|
verbose: Boolean((params as { verbose?: boolean }).verbose),
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
respond(true, result, undefined);
|
respond(true, result, undefined);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -56,14 +63,19 @@ export const webHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const accountId =
|
||||||
|
typeof (params as { accountId?: unknown }).accountId === "string"
|
||||||
|
? (params as { accountId?: string }).accountId
|
||||||
|
: undefined;
|
||||||
const result = await waitForWebLogin({
|
const result = await waitForWebLogin({
|
||||||
timeoutMs:
|
timeoutMs:
|
||||||
typeof (params as { timeoutMs?: unknown }).timeoutMs === "number"
|
typeof (params as { timeoutMs?: unknown }).timeoutMs === "number"
|
||||||
? (params as { timeoutMs?: number }).timeoutMs
|
? (params as { timeoutMs?: number }).timeoutMs
|
||||||
: undefined,
|
: undefined,
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
if (result.connected) {
|
if (result.connected) {
|
||||||
await context.startWhatsAppProvider();
|
await context.startWhatsAppProvider(accountId);
|
||||||
}
|
}
|
||||||
respond(true, result, undefined);
|
respond(true, result, undefined);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -74,11 +86,26 @@ export const webHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web.logout": async ({ respond, context }) => {
|
"web.logout": async ({ params, respond, context }) => {
|
||||||
try {
|
try {
|
||||||
await context.stopWhatsAppProvider();
|
const rawAccountId =
|
||||||
const cleared = await logoutWeb(defaultRuntime);
|
params && typeof params === "object" && "accountId" in params
|
||||||
context.markWhatsAppLoggedOut(cleared);
|
? (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);
|
respond(true, { cleared }, undefined);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
respond(
|
respond(
|
||||||
|
|||||||
@ -15,6 +15,10 @@ import {
|
|||||||
import { monitorTelegramProvider } from "../telegram/monitor.js";
|
import { monitorTelegramProvider } from "../telegram/monitor.js";
|
||||||
import { probeTelegram } from "../telegram/probe.js";
|
import { probeTelegram } from "../telegram/probe.js";
|
||||||
import { resolveTelegramToken } from "../telegram/token.js";
|
import { resolveTelegramToken } from "../telegram/token.js";
|
||||||
|
import {
|
||||||
|
listEnabledWhatsAppAccounts,
|
||||||
|
resolveDefaultWhatsAppAccountId,
|
||||||
|
} from "../web/accounts.js";
|
||||||
import type { WebProviderStatus } from "../web/auto-reply.js";
|
import type { WebProviderStatus } from "../web/auto-reply.js";
|
||||||
import { readWebSelfId } from "../web/session.js";
|
import { readWebSelfId } from "../web/session.js";
|
||||||
import { formatError } from "./server-utils.js";
|
import { formatError } from "./server-utils.js";
|
||||||
@ -60,6 +64,7 @@ export type IMessageRuntimeStatus = {
|
|||||||
|
|
||||||
export type ProviderRuntimeSnapshot = {
|
export type ProviderRuntimeSnapshot = {
|
||||||
whatsapp: WebProviderStatus;
|
whatsapp: WebProviderStatus;
|
||||||
|
whatsappAccounts?: Record<string, WebProviderStatus>;
|
||||||
telegram: TelegramRuntimeStatus;
|
telegram: TelegramRuntimeStatus;
|
||||||
discord: DiscordRuntimeStatus;
|
discord: DiscordRuntimeStatus;
|
||||||
slack: SlackRuntimeStatus;
|
slack: SlackRuntimeStatus;
|
||||||
@ -88,8 +93,8 @@ type ProviderManagerOptions = {
|
|||||||
export type ProviderManager = {
|
export type ProviderManager = {
|
||||||
getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
|
getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
|
||||||
startProviders: () => Promise<void>;
|
startProviders: () => Promise<void>;
|
||||||
startWhatsAppProvider: () => Promise<void>;
|
startWhatsAppProvider: (accountId?: string) => Promise<void>;
|
||||||
stopWhatsAppProvider: () => Promise<void>;
|
stopWhatsAppProvider: (accountId?: string) => Promise<void>;
|
||||||
startTelegramProvider: () => Promise<void>;
|
startTelegramProvider: () => Promise<void>;
|
||||||
stopTelegramProvider: () => Promise<void>;
|
stopTelegramProvider: () => Promise<void>;
|
||||||
startDiscordProvider: () => Promise<void>;
|
startDiscordProvider: () => Promise<void>;
|
||||||
@ -100,7 +105,7 @@ export type ProviderManager = {
|
|||||||
stopSignalProvider: () => Promise<void>;
|
stopSignalProvider: () => Promise<void>;
|
||||||
startIMessageProvider: () => Promise<void>;
|
startIMessageProvider: () => Promise<void>;
|
||||||
stopIMessageProvider: () => Promise<void>;
|
stopIMessageProvider: () => Promise<void>;
|
||||||
markWhatsAppLoggedOut: (cleared: boolean) => void;
|
markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createProviderManager(
|
export function createProviderManager(
|
||||||
@ -122,20 +127,21 @@ export function createProviderManager(
|
|||||||
imessageRuntimeEnv,
|
imessageRuntimeEnv,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
let whatsappAbort: AbortController | null = null;
|
const whatsappAborts = new Map<string, AbortController>();
|
||||||
let telegramAbort: AbortController | null = null;
|
let telegramAbort: AbortController | null = null;
|
||||||
let discordAbort: AbortController | null = null;
|
let discordAbort: AbortController | null = null;
|
||||||
let slackAbort: AbortController | null = null;
|
let slackAbort: AbortController | null = null;
|
||||||
let signalAbort: AbortController | null = null;
|
let signalAbort: AbortController | null = null;
|
||||||
let imessageAbort: AbortController | null = null;
|
let imessageAbort: AbortController | null = null;
|
||||||
let whatsappTask: Promise<unknown> | null = null;
|
const whatsappTasks = new Map<string, Promise<unknown>>();
|
||||||
let telegramTask: Promise<unknown> | null = null;
|
let telegramTask: Promise<unknown> | null = null;
|
||||||
let discordTask: Promise<unknown> | null = null;
|
let discordTask: Promise<unknown> | null = null;
|
||||||
let slackTask: Promise<unknown> | null = null;
|
let slackTask: Promise<unknown> | null = null;
|
||||||
let signalTask: Promise<unknown> | null = null;
|
let signalTask: Promise<unknown> | null = null;
|
||||||
let imessageTask: Promise<unknown> | null = null;
|
let imessageTask: Promise<unknown> | null = null;
|
||||||
|
|
||||||
let whatsappRuntime: WebProviderStatus = {
|
const whatsappRuntimes = new Map<string, WebProviderStatus>();
|
||||||
|
const defaultWhatsAppStatus = (): WebProviderStatus => ({
|
||||||
running: false,
|
running: false,
|
||||||
connected: false,
|
connected: false,
|
||||||
reconnectAttempts: 0,
|
reconnectAttempts: 0,
|
||||||
@ -144,7 +150,7 @@ export function createProviderManager(
|
|||||||
lastMessageAt: null,
|
lastMessageAt: null,
|
||||||
lastEventAt: null,
|
lastEventAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
};
|
});
|
||||||
let telegramRuntime: TelegramRuntimeStatus = {
|
let telegramRuntime: TelegramRuntimeStatus = {
|
||||||
running: false,
|
running: false,
|
||||||
lastStartAt: null,
|
lastStartAt: null,
|
||||||
@ -180,86 +186,134 @@ export function createProviderManager(
|
|||||||
dbPath: null,
|
dbPath: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateWhatsAppStatus = (next: WebProviderStatus) => {
|
const updateWhatsAppStatus = (accountId: string, next: WebProviderStatus) => {
|
||||||
whatsappRuntime = next;
|
whatsappRuntimes.set(accountId, next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startWhatsAppProvider = async () => {
|
const startWhatsAppProvider = async (accountId?: string) => {
|
||||||
if (whatsappTask) return;
|
|
||||||
const cfg = loadConfig();
|
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) {
|
if (cfg.web?.enabled === false) {
|
||||||
whatsappRuntime = {
|
for (const account of targets) {
|
||||||
...whatsappRuntime,
|
const current =
|
||||||
running: false,
|
whatsappRuntimes.get(account.accountId) ?? defaultWhatsAppStatus();
|
||||||
connected: false,
|
whatsappRuntimes.set(account.accountId, {
|
||||||
lastError: "disabled",
|
...current,
|
||||||
};
|
running: false,
|
||||||
|
connected: false,
|
||||||
|
lastError: "disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
logWhatsApp.info("skipping provider start (web.enabled=false)");
|
logWhatsApp.info("skipping provider start (web.enabled=false)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!(await webAuthExists())) {
|
|
||||||
whatsappRuntime = {
|
await Promise.all(
|
||||||
...whatsappRuntime,
|
targets.map(async (account) => {
|
||||||
running: false,
|
if (whatsappTasks.has(account.accountId)) return;
|
||||||
connected: false,
|
const current =
|
||||||
lastError: "not linked",
|
whatsappRuntimes.get(account.accountId) ?? defaultWhatsAppStatus();
|
||||||
};
|
if (!(await webAuthExists(account.authDir))) {
|
||||||
logWhatsApp.info("skipping provider start (no linked session)");
|
whatsappRuntimes.set(account.accountId, {
|
||||||
return;
|
...current,
|
||||||
}
|
running: false,
|
||||||
const { e164, jid } = readWebSelfId();
|
connected: false,
|
||||||
const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown";
|
lastError: "not linked",
|
||||||
logWhatsApp.info(`starting provider (${identity})`);
|
});
|
||||||
whatsappAbort = new AbortController();
|
logWhatsApp.info(
|
||||||
whatsappRuntime = {
|
`[${account.accountId}] skipping provider start (no linked session)`,
|
||||||
...whatsappRuntime,
|
);
|
||||||
running: true,
|
return;
|
||||||
connected: false,
|
}
|
||||||
lastError: null,
|
|
||||||
};
|
const { e164, jid } = readWebSelfId(account.authDir);
|
||||||
const task = monitorWebProvider(
|
const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown";
|
||||||
shouldLogVerbose(),
|
logWhatsApp.info(
|
||||||
undefined,
|
`[${account.accountId}] starting provider (${identity})`,
|
||||||
true,
|
);
|
||||||
undefined,
|
const abort = new AbortController();
|
||||||
whatsappRuntimeEnv,
|
whatsappAborts.set(account.accountId, abort);
|
||||||
whatsappAbort.signal,
|
whatsappRuntimes.set(account.accountId, {
|
||||||
{ statusSink: updateWhatsAppStatus },
|
...current,
|
||||||
)
|
running: true,
|
||||||
.catch((err) => {
|
|
||||||
whatsappRuntime = {
|
|
||||||
...whatsappRuntime,
|
|
||||||
lastError: formatError(err),
|
|
||||||
};
|
|
||||||
logWhatsApp.error(`provider exited: ${formatError(err)}`);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
whatsappAbort = null;
|
|
||||||
whatsappTask = null;
|
|
||||||
whatsappRuntime = {
|
|
||||||
...whatsappRuntime,
|
|
||||||
running: false,
|
|
||||||
connected: false,
|
connected: false,
|
||||||
};
|
lastError: null,
|
||||||
});
|
});
|
||||||
whatsappTask = task;
|
|
||||||
|
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 () => {
|
const stopWhatsAppProvider = async (accountId?: string) => {
|
||||||
if (!whatsappAbort && !whatsappTask) return;
|
const ids = accountId
|
||||||
whatsappAbort?.abort();
|
? [accountId]
|
||||||
try {
|
: Array.from(
|
||||||
await whatsappTask;
|
new Set([...whatsappAborts.keys(), ...whatsappTasks.keys()]),
|
||||||
} catch {
|
);
|
||||||
// ignore
|
await Promise.all(
|
||||||
}
|
ids.map(async (id) => {
|
||||||
whatsappAbort = null;
|
const abort = whatsappAborts.get(id);
|
||||||
whatsappTask = null;
|
const task = whatsappTasks.get(id);
|
||||||
whatsappRuntime = {
|
if (!abort && !task) return;
|
||||||
...whatsappRuntime,
|
abort?.abort();
|
||||||
running: false,
|
try {
|
||||||
connected: false,
|
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 () => {
|
const startTelegramProvider = async () => {
|
||||||
@ -754,23 +808,38 @@ export function createProviderManager(
|
|||||||
await startIMessageProvider();
|
await startIMessageProvider();
|
||||||
};
|
};
|
||||||
|
|
||||||
const markWhatsAppLoggedOut = (cleared: boolean) => {
|
const markWhatsAppLoggedOut = (cleared: boolean, accountId?: string) => {
|
||||||
whatsappRuntime = {
|
const cfg = loadConfig();
|
||||||
...whatsappRuntime,
|
const resolvedId = accountId ?? resolveDefaultWhatsAppAccountId(cfg);
|
||||||
|
const current = whatsappRuntimes.get(resolvedId) ?? defaultWhatsAppStatus();
|
||||||
|
whatsappRuntimes.set(resolvedId, {
|
||||||
|
...current,
|
||||||
running: false,
|
running: false,
|
||||||
connected: false,
|
connected: false,
|
||||||
lastError: cleared ? "logged out" : whatsappRuntime.lastError,
|
lastError: cleared ? "logged out" : current.lastError,
|
||||||
};
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRuntimeSnapshot = (): ProviderRuntimeSnapshot => ({
|
const getRuntimeSnapshot = (): ProviderRuntimeSnapshot => {
|
||||||
whatsapp: { ...whatsappRuntime },
|
const cfg = loadConfig();
|
||||||
telegram: { ...telegramRuntime },
|
const defaultId = resolveDefaultWhatsAppAccountId(cfg);
|
||||||
discord: { ...discordRuntime },
|
const whatsapp = whatsappRuntimes.get(defaultId) ?? defaultWhatsAppStatus();
|
||||||
slack: { ...slackRuntime },
|
const whatsappAccounts = Object.fromEntries(
|
||||||
signal: { ...signalRuntime },
|
Array.from(whatsappRuntimes.entries()).map(([id, status]) => [
|
||||||
imessage: { ...imessageRuntime },
|
id,
|
||||||
});
|
{ ...status },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
whatsapp: { ...whatsapp },
|
||||||
|
whatsappAccounts,
|
||||||
|
telegram: { ...telegramRuntime },
|
||||||
|
discord: { ...discordRuntime },
|
||||||
|
slack: { ...slackRuntime },
|
||||||
|
signal: { ...signalRuntime },
|
||||||
|
imessage: { ...imessageRuntime },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getRuntimeSnapshot,
|
getRuntimeSnapshot,
|
||||||
|
|||||||
@ -33,7 +33,7 @@ describe("gateway server agent", () => {
|
|||||||
main: {
|
main: {
|
||||||
sessionId: "sess-main-stale",
|
sessionId: "sess-main-stale",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
lastChannel: "whatsapp",
|
lastProvider: "whatsapp",
|
||||||
lastTo: "+1555",
|
lastTo: "+1555",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -49,7 +49,7 @@ describe("gateway server agent", () => {
|
|||||||
const res = await rpcReq(ws, "agent", {
|
const res = await rpcReq(ws, "agent", {
|
||||||
message: "hi",
|
message: "hi",
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
channel: "last",
|
provider: "last",
|
||||||
deliver: true,
|
deliver: true,
|
||||||
idempotencyKey: "idem-agent-last-stale",
|
idempotencyKey: "idem-agent-last-stale",
|
||||||
});
|
});
|
||||||
@ -76,7 +76,7 @@ describe("gateway server agent", () => {
|
|||||||
main: {
|
main: {
|
||||||
sessionId: "sess-main-whatsapp",
|
sessionId: "sess-main-whatsapp",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
lastChannel: "whatsapp",
|
lastProvider: "whatsapp",
|
||||||
lastTo: "+1555",
|
lastTo: "+1555",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -92,7 +92,7 @@ describe("gateway server agent", () => {
|
|||||||
const res = await rpcReq(ws, "agent", {
|
const res = await rpcReq(ws, "agent", {
|
||||||
message: "hi",
|
message: "hi",
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
channel: "last",
|
provider: "last",
|
||||||
deliver: true,
|
deliver: true,
|
||||||
idempotencyKey: "idem-agent-last-whatsapp",
|
idempotencyKey: "idem-agent-last-whatsapp",
|
||||||
});
|
});
|
||||||
@ -120,7 +120,7 @@ describe("gateway server agent", () => {
|
|||||||
main: {
|
main: {
|
||||||
sessionId: "sess-main",
|
sessionId: "sess-main",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
lastChannel: "telegram",
|
lastProvider: "telegram",
|
||||||
lastTo: "123",
|
lastTo: "123",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -136,7 +136,7 @@ describe("gateway server agent", () => {
|
|||||||
const res = await rpcReq(ws, "agent", {
|
const res = await rpcReq(ws, "agent", {
|
||||||
message: "hi",
|
message: "hi",
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
channel: "last",
|
provider: "last",
|
||||||
deliver: true,
|
deliver: true,
|
||||||
idempotencyKey: "idem-agent-last",
|
idempotencyKey: "idem-agent-last",
|
||||||
});
|
});
|
||||||
@ -164,7 +164,7 @@ describe("gateway server agent", () => {
|
|||||||
main: {
|
main: {
|
||||||
sessionId: "sess-discord",
|
sessionId: "sess-discord",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
lastChannel: "discord",
|
lastProvider: "discord",
|
||||||
lastTo: "channel:discord-123",
|
lastTo: "channel:discord-123",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -180,7 +180,7 @@ describe("gateway server agent", () => {
|
|||||||
const res = await rpcReq(ws, "agent", {
|
const res = await rpcReq(ws, "agent", {
|
||||||
message: "hi",
|
message: "hi",
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
channel: "last",
|
provider: "last",
|
||||||
deliver: true,
|
deliver: true,
|
||||||
idempotencyKey: "idem-agent-last-discord",
|
idempotencyKey: "idem-agent-last-discord",
|
||||||
});
|
});
|
||||||
@ -208,7 +208,7 @@ describe("gateway server agent", () => {
|
|||||||
main: {
|
main: {
|
||||||
sessionId: "sess-signal",
|
sessionId: "sess-signal",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
lastChannel: "signal",
|
lastProvider: "signal",
|
||||||
lastTo: "+15551234567",
|
lastTo: "+15551234567",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -224,7 +224,7 @@ describe("gateway server agent", () => {
|
|||||||
const res = await rpcReq(ws, "agent", {
|
const res = await rpcReq(ws, "agent", {
|
||||||
message: "hi",
|
message: "hi",
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
channel: "last",
|
provider: "last",
|
||||||
deliver: true,
|
deliver: true,
|
||||||
idempotencyKey: "idem-agent-last-signal",
|
idempotencyKey: "idem-agent-last-signal",
|
||||||
});
|
});
|
||||||
@ -253,7 +253,7 @@ describe("gateway server agent", () => {
|
|||||||
main: {
|
main: {
|
||||||
sessionId: "sess-main-webchat",
|
sessionId: "sess-main-webchat",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
lastChannel: "webchat",
|
lastProvider: "webchat",
|
||||||
lastTo: "+1555",
|
lastTo: "+1555",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -269,7 +269,7 @@ describe("gateway server agent", () => {
|
|||||||
const res = await rpcReq(ws, "agent", {
|
const res = await rpcReq(ws, "agent", {
|
||||||
message: "hi",
|
message: "hi",
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
channel: "last",
|
provider: "last",
|
||||||
deliver: true,
|
deliver: true,
|
||||||
idempotencyKey: "idem-agent-webchat",
|
idempotencyKey: "idem-agent-webchat",
|
||||||
});
|
});
|
||||||
|
|||||||
@ -70,7 +70,7 @@ describe("gateway server chat", () => {
|
|||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
action: "deny",
|
action: "deny",
|
||||||
match: { surface: "discord", chatType: "group" },
|
match: { provider: "discord", chatType: "group" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -84,7 +84,7 @@ describe("gateway server chat", () => {
|
|||||||
sessionId: "sess-discord",
|
sessionId: "sess-discord",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
chatType: "group",
|
chatType: "group",
|
||||||
surface: "discord",
|
provider: "discord",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
@ -423,7 +423,7 @@ describe("gateway server chat", () => {
|
|||||||
main: {
|
main: {
|
||||||
sessionId: "sess-main",
|
sessionId: "sess-main",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
lastChannel: "whatsapp",
|
lastProvider: "whatsapp",
|
||||||
lastTo: "+1555",
|
lastTo: "+1555",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -446,9 +446,9 @@ describe("gateway server chat", () => {
|
|||||||
const stored = JSON.parse(
|
const stored = JSON.parse(
|
||||||
await fs.readFile(testState.sessionStorePath, "utf-8"),
|
await fs.readFile(testState.sessionStorePath, "utf-8"),
|
||||||
) as {
|
) 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");
|
expect(stored.main?.lastTo).toBe("+1555");
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|||||||
@ -86,7 +86,7 @@ describe("gateway server hooks", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hooks agent rejects invalid channel", async () => {
|
test("hooks agent rejects invalid provider", async () => {
|
||||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const server = await startGatewayServer(port);
|
const server = await startGatewayServer(port);
|
||||||
@ -96,7 +96,7 @@ describe("gateway server hooks", () => {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: "Bearer hook-secret",
|
Authorization: "Bearer hook-secret",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ message: "Nope", channel: "sms" }),
|
body: JSON.stringify({ message: "Nope", provider: "sms" }),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(peekSystemEvents().length).toBe(0);
|
expect(peekSystemEvents().length).toBe(0);
|
||||||
|
|||||||
@ -732,7 +732,7 @@ describe("gateway server node/bridge", () => {
|
|||||||
main: {
|
main: {
|
||||||
sessionId: "sess-main",
|
sessionId: "sess-main",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
lastChannel: "whatsapp",
|
lastProvider: "whatsapp",
|
||||||
lastTo: "+1555",
|
lastTo: "+1555",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -759,7 +759,7 @@ describe("gateway server node/bridge", () => {
|
|||||||
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||||
expect(call.sessionId).toBe("sess-main");
|
expect(call.sessionId).toBe("sess-main");
|
||||||
expect(call.deliver).toBe(false);
|
expect(call.deliver).toBe(false);
|
||||||
expect(call.surface).toBe("Node");
|
expect(call.messageProvider).toBe("node");
|
||||||
|
|
||||||
const stored = JSON.parse(
|
const stored = JSON.parse(
|
||||||
await fs.readFile(testState.sessionStorePath, "utf-8"),
|
await fs.readFile(testState.sessionStorePath, "utf-8"),
|
||||||
|
|||||||
@ -40,23 +40,26 @@ describe("gateway server sessions", () => {
|
|||||||
storePath,
|
storePath,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
main: {
|
"agent:main:main": {
|
||||||
sessionId: "sess-main",
|
sessionId: "sess-main",
|
||||||
updatedAt: now - 30_000,
|
updatedAt: now - 30_000,
|
||||||
inputTokens: 10,
|
inputTokens: 10,
|
||||||
outputTokens: 20,
|
outputTokens: 20,
|
||||||
thinkingLevel: "low",
|
thinkingLevel: "low",
|
||||||
verboseLevel: "on",
|
verboseLevel: "on",
|
||||||
|
lastProvider: "whatsapp",
|
||||||
|
lastTo: "+1555",
|
||||||
|
lastAccountId: "work",
|
||||||
},
|
},
|
||||||
"discord:group:dev": {
|
"agent:main:discord:group:dev": {
|
||||||
sessionId: "sess-group",
|
sessionId: "sess-group",
|
||||||
updatedAt: now - 120_000,
|
updatedAt: now - 120_000,
|
||||||
totalTokens: 50,
|
totalTokens: 50,
|
||||||
},
|
},
|
||||||
"subagent:one": {
|
"agent:main:subagent:one": {
|
||||||
sessionId: "sess-subagent",
|
sessionId: "sess-subagent",
|
||||||
updatedAt: now - 120_000,
|
updatedAt: now - 120_000,
|
||||||
spawnedBy: "main",
|
spawnedBy: "agent:main:main",
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
sessionId: "sess-global",
|
sessionId: "sess-global",
|
||||||
@ -91,16 +94,20 @@ describe("gateway server sessions", () => {
|
|||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
thinkingLevel?: string;
|
thinkingLevel?: string;
|
||||||
verboseLevel?: string;
|
verboseLevel?: string;
|
||||||
|
lastAccountId?: string;
|
||||||
}>;
|
}>;
|
||||||
}>(ws, "sessions.list", { includeGlobal: false, includeUnknown: false });
|
}>(ws, "sessions.list", { includeGlobal: false, includeUnknown: false });
|
||||||
|
|
||||||
expect(list1.ok).toBe(true);
|
expect(list1.ok).toBe(true);
|
||||||
expect(list1.payload?.path).toBe(storePath);
|
expect(list1.payload?.path).toBe(storePath);
|
||||||
expect(list1.payload?.sessions.some((s) => s.key === "global")).toBe(false);
|
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?.totalTokens).toBe(30);
|
||||||
expect(main?.thinkingLevel).toBe("low");
|
expect(main?.thinkingLevel).toBe("low");
|
||||||
expect(main?.verboseLevel).toBe("on");
|
expect(main?.verboseLevel).toBe("on");
|
||||||
|
expect(main?.lastAccountId).toBe("work");
|
||||||
|
|
||||||
const active = await rpcReq<{
|
const active = await rpcReq<{
|
||||||
sessions: Array<{ key: string }>;
|
sessions: Array<{ key: string }>;
|
||||||
@ -110,7 +117,9 @@ describe("gateway server sessions", () => {
|
|||||||
activeMinutes: 1,
|
activeMinutes: 1,
|
||||||
});
|
});
|
||||||
expect(active.ok).toBe(true);
|
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<{
|
const limited = await rpcReq<{
|
||||||
sessions: Array<{ key: string }>;
|
sessions: Array<{ key: string }>;
|
||||||
@ -126,16 +135,16 @@ describe("gateway server sessions", () => {
|
|||||||
const patched = await rpcReq<{ ok: true; key: string }>(
|
const patched = await rpcReq<{ ok: true; key: string }>(
|
||||||
ws,
|
ws,
|
||||||
"sessions.patch",
|
"sessions.patch",
|
||||||
{ key: "main", thinkingLevel: "medium", verboseLevel: null },
|
{ key: "agent:main:main", thinkingLevel: "medium", verboseLevel: null },
|
||||||
);
|
);
|
||||||
expect(patched.ok).toBe(true);
|
expect(patched.ok).toBe(true);
|
||||||
expect(patched.payload?.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<{
|
const sendPolicyPatched = await rpcReq<{
|
||||||
ok: true;
|
ok: true;
|
||||||
entry: { sendPolicy?: string };
|
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.ok).toBe(true);
|
||||||
expect(sendPolicyPatched.payload?.entry.sendPolicy).toBe("deny");
|
expect(sendPolicyPatched.payload?.entry.sendPolicy).toBe("deny");
|
||||||
|
|
||||||
@ -148,7 +157,9 @@ describe("gateway server sessions", () => {
|
|||||||
}>;
|
}>;
|
||||||
}>(ws, "sessions.list", {});
|
}>(ws, "sessions.list", {});
|
||||||
expect(list2.ok).toBe(true);
|
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?.thinkingLevel).toBe("medium");
|
||||||
expect(main2?.verboseLevel).toBeUndefined();
|
expect(main2?.verboseLevel).toBeUndefined();
|
||||||
expect(main2?.sendPolicy).toBe("deny");
|
expect(main2?.sendPolicy).toBe("deny");
|
||||||
@ -158,23 +169,26 @@ describe("gateway server sessions", () => {
|
|||||||
}>(ws, "sessions.list", {
|
}>(ws, "sessions.list", {
|
||||||
includeGlobal: true,
|
includeGlobal: true,
|
||||||
includeUnknown: true,
|
includeUnknown: true,
|
||||||
spawnedBy: "main",
|
spawnedBy: "agent:main:main",
|
||||||
});
|
});
|
||||||
expect(spawnedOnly.ok).toBe(true);
|
expect(spawnedOnly.ok).toBe(true);
|
||||||
expect(spawnedOnly.payload?.sessions.map((s) => s.key)).toEqual([
|
expect(spawnedOnly.payload?.sessions.map((s) => s.key)).toEqual([
|
||||||
"subagent:one",
|
"agent:main:subagent:one",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const spawnedPatched = await rpcReq<{
|
const spawnedPatched = await rpcReq<{
|
||||||
ok: true;
|
ok: true;
|
||||||
entry: { spawnedBy?: string };
|
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.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", {
|
const spawnedPatchedInvalidKey = await rpcReq(ws, "sessions.patch", {
|
||||||
key: "main",
|
key: "agent:main:main",
|
||||||
spawnedBy: "main",
|
spawnedBy: "agent:main:main",
|
||||||
});
|
});
|
||||||
expect(spawnedPatchedInvalidKey.ok).toBe(false);
|
expect(spawnedPatchedInvalidKey.ok).toBe(false);
|
||||||
|
|
||||||
@ -183,7 +197,10 @@ describe("gateway server sessions", () => {
|
|||||||
const modelPatched = await rpcReq<{
|
const modelPatched = await rpcReq<{
|
||||||
ok: true;
|
ok: true;
|
||||||
entry: { modelOverride?: string; providerOverride?: string };
|
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.ok).toBe(true);
|
||||||
expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a");
|
expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a");
|
||||||
expect(modelPatched.payload?.entry.providerOverride).toBe("openai");
|
expect(modelPatched.payload?.entry.providerOverride).toBe("openai");
|
||||||
@ -191,7 +208,7 @@ describe("gateway server sessions", () => {
|
|||||||
const compacted = await rpcReq<{ ok: true; compacted: boolean }>(
|
const compacted = await rpcReq<{ ok: true; compacted: boolean }>(
|
||||||
ws,
|
ws,
|
||||||
"sessions.compact",
|
"sessions.compact",
|
||||||
{ key: "main", maxLines: 3 },
|
{ key: "agent:main:main", maxLines: 3 },
|
||||||
);
|
);
|
||||||
expect(compacted.ok).toBe(true);
|
expect(compacted.ok).toBe(true);
|
||||||
expect(compacted.payload?.compacted).toBe(true);
|
expect(compacted.payload?.compacted).toBe(true);
|
||||||
@ -209,7 +226,7 @@ describe("gateway server sessions", () => {
|
|||||||
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(
|
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(
|
||||||
ws,
|
ws,
|
||||||
"sessions.delete",
|
"sessions.delete",
|
||||||
{ key: "discord:group:dev" },
|
{ key: "agent:main:discord:group:dev" },
|
||||||
);
|
);
|
||||||
expect(deleted.ok).toBe(true);
|
expect(deleted.ok).toBe(true);
|
||||||
expect(deleted.payload?.deleted).toBe(true);
|
expect(deleted.payload?.deleted).toBe(true);
|
||||||
@ -219,7 +236,7 @@ describe("gateway server sessions", () => {
|
|||||||
expect(listAfterDelete.ok).toBe(true);
|
expect(listAfterDelete.ok).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
listAfterDelete.payload?.sessions.some(
|
listAfterDelete.payload?.sessions.some(
|
||||||
(s) => s.key === "discord:group:dev",
|
(s) => s.key === "agent:main:discord:group:dev",
|
||||||
),
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
const filesAfterDelete = await fs.readdir(dir);
|
const filesAfterDelete = await fs.readdir(dir);
|
||||||
@ -231,13 +248,13 @@ describe("gateway server sessions", () => {
|
|||||||
ok: true;
|
ok: true;
|
||||||
key: string;
|
key: string;
|
||||||
entry: { sessionId: string };
|
entry: { sessionId: string };
|
||||||
}>(ws, "sessions.reset", { key: "main" });
|
}>(ws, "sessions.reset", { key: "agent:main:main" });
|
||||||
expect(reset.ok).toBe(true);
|
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");
|
expect(reset.payload?.entry.sessionId).not.toBe("sess-main");
|
||||||
|
|
||||||
const badThinking = await rpcReq(ws, "sessions.patch", {
|
const badThinking = await rpcReq(ws, "sessions.patch", {
|
||||||
key: "main",
|
key: "agent:main:main",
|
||||||
thinkingLevel: "banana",
|
thinkingLevel: "banana",
|
||||||
});
|
});
|
||||||
expect(badThinking.ok).toBe(false);
|
expect(badThinking.ok).toBe(false);
|
||||||
|
|||||||
@ -482,7 +482,7 @@ export async function startGatewayServer(
|
|||||||
wakeMode: "now" | "next-heartbeat";
|
wakeMode: "now" | "next-heartbeat";
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
deliver: boolean;
|
deliver: boolean;
|
||||||
channel:
|
provider:
|
||||||
| "last"
|
| "last"
|
||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
| "telegram"
|
| "telegram"
|
||||||
@ -514,7 +514,7 @@ export async function startGatewayServer(
|
|||||||
thinking: value.thinking,
|
thinking: value.thinking,
|
||||||
timeoutSeconds: value.timeoutSeconds,
|
timeoutSeconds: value.timeoutSeconds,
|
||||||
deliver: value.deliver,
|
deliver: value.deliver,
|
||||||
channel: value.channel,
|
provider: value.provider,
|
||||||
to: value.to,
|
to: value.to,
|
||||||
},
|
},
|
||||||
state: { nextRunAtMs: now },
|
state: { nextRunAtMs: now },
|
||||||
|
|||||||
@ -15,7 +15,7 @@ describe("gateway session utils", () => {
|
|||||||
test("parseGroupKey handles group prefixes", () => {
|
test("parseGroupKey handles group prefixes", () => {
|
||||||
expect(parseGroupKey("group:abc")).toEqual({ id: "abc" });
|
expect(parseGroupKey("group:abc")).toEqual({ id: "abc" });
|
||||||
expect(parseGroupKey("discord:group:dev")).toEqual({
|
expect(parseGroupKey("discord:group:dev")).toEqual({
|
||||||
surface: "discord",
|
provider: "discord",
|
||||||
kind: "group",
|
kind: "group",
|
||||||
id: "dev",
|
id: "dev",
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,12 +9,19 @@ import {
|
|||||||
} from "../agents/defaults.js";
|
} from "../agents/defaults.js";
|
||||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||||
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
|
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
|
||||||
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
import {
|
import {
|
||||||
buildGroupDisplayName,
|
buildGroupDisplayName,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
resolveAgentIdFromSessionKey,
|
||||||
|
resolveSessionTranscriptPath,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
|
import {
|
||||||
|
normalizeAgentId,
|
||||||
|
parseAgentSessionKey,
|
||||||
|
} from "../routing/session-key.js";
|
||||||
|
|
||||||
export type GatewaySessionsDefaults = {
|
export type GatewaySessionsDefaults = {
|
||||||
model: string | null;
|
model: string | null;
|
||||||
@ -25,7 +32,7 @@ export type GatewaySessionRow = {
|
|||||||
key: string;
|
key: string;
|
||||||
kind: "direct" | "group" | "global" | "unknown";
|
kind: "direct" | "group" | "global" | "unknown";
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
surface?: string;
|
provider?: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
room?: string;
|
room?: string;
|
||||||
space?: string;
|
space?: string;
|
||||||
@ -43,8 +50,9 @@ export type GatewaySessionRow = {
|
|||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
contextTokens?: number;
|
contextTokens?: number;
|
||||||
lastChannel?: SessionEntry["lastChannel"];
|
lastProvider?: SessionEntry["lastProvider"];
|
||||||
lastTo?: string;
|
lastTo?: string;
|
||||||
|
lastAccountId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SessionsListResult = {
|
export type SessionsListResult = {
|
||||||
@ -90,12 +98,16 @@ export function readSessionMessages(
|
|||||||
export function resolveSessionTranscriptCandidates(
|
export function resolveSessionTranscriptCandidates(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
storePath: string | undefined,
|
storePath: string | undefined,
|
||||||
|
agentId?: string,
|
||||||
): string[] {
|
): string[] {
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
if (storePath) {
|
if (storePath) {
|
||||||
const dir = path.dirname(storePath);
|
const dir = path.dirname(storePath);
|
||||||
candidates.push(path.join(dir, `${sessionId}.jsonl`));
|
candidates.push(path.join(dir, `${sessionId}.jsonl`));
|
||||||
}
|
}
|
||||||
|
if (agentId) {
|
||||||
|
candidates.push(resolveSessionTranscriptPath(sessionId, agentId));
|
||||||
|
}
|
||||||
candidates.push(
|
candidates.push(
|
||||||
path.join(os.homedir(), ".clawdbot", "sessions", `${sessionId}.jsonl`),
|
path.join(os.homedir(), ".clawdbot", "sessions", `${sessionId}.jsonl`),
|
||||||
);
|
);
|
||||||
@ -136,11 +148,12 @@ export function capArrayByJsonBytes<T>(
|
|||||||
export function loadSessionEntry(sessionKey: string) {
|
export function loadSessionEntry(sessionKey: string) {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
const storePath = sessionCfg?.store
|
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||||
? resolveStorePath(sessionCfg.store)
|
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
|
||||||
: resolveStorePath(undefined);
|
|
||||||
const store = loadSessionStore(storePath);
|
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 };
|
return { cfg, storePath, store, entry };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,22 +176,167 @@ export function classifySessionKey(
|
|||||||
|
|
||||||
export function parseGroupKey(
|
export function parseGroupKey(
|
||||||
key: string,
|
key: string,
|
||||||
): { surface?: string; kind?: "group" | "channel"; id?: string } | null {
|
): { provider?: string; kind?: "group" | "channel"; id?: string } | null {
|
||||||
if (key.startsWith("group:")) {
|
const agentParsed = parseAgentSessionKey(key);
|
||||||
const raw = key.slice("group:".length);
|
const rawKey = agentParsed?.rest ?? key;
|
||||||
|
if (rawKey.startsWith("group:")) {
|
||||||
|
const raw = rawKey.slice("group:".length);
|
||||||
return raw ? { id: raw } : null;
|
return raw ? { id: raw } : null;
|
||||||
}
|
}
|
||||||
const parts = key.split(":").filter(Boolean);
|
const parts = rawKey.split(":").filter(Boolean);
|
||||||
if (parts.length >= 3) {
|
if (parts.length >= 3) {
|
||||||
const [surface, kind, ...rest] = parts;
|
const [provider, kind, ...rest] = parts;
|
||||||
if (kind === "group" || kind === "channel") {
|
if (kind === "group" || kind === "channel") {
|
||||||
const id = rest.join(":");
|
const id = rest.join(":");
|
||||||
return { surface, kind, id };
|
return { provider, kind, id };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
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<string>();
|
||||||
|
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<string, SessionEntry>;
|
||||||
|
} {
|
||||||
|
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<string, SessionEntry> = {};
|
||||||
|
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<string, SessionEntry> = {};
|
||||||
|
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(
|
export function getSessionDefaults(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
): GatewaySessionsDefaults {
|
): GatewaySessionsDefaults {
|
||||||
@ -251,16 +409,16 @@ export function listSessionsFromStore(params: {
|
|||||||
const output = entry?.outputTokens ?? 0;
|
const output = entry?.outputTokens ?? 0;
|
||||||
const total = entry?.totalTokens ?? input + output;
|
const total = entry?.totalTokens ?? input + output;
|
||||||
const parsed = parseGroupKey(key);
|
const parsed = parseGroupKey(key);
|
||||||
const surface = entry?.surface ?? parsed?.surface;
|
const provider = entry?.provider ?? parsed?.provider;
|
||||||
const subject = entry?.subject;
|
const subject = entry?.subject;
|
||||||
const room = entry?.room;
|
const room = entry?.room;
|
||||||
const space = entry?.space;
|
const space = entry?.space;
|
||||||
const id = parsed?.id;
|
const id = parsed?.id;
|
||||||
const displayName =
|
const displayName =
|
||||||
entry?.displayName ??
|
entry?.displayName ??
|
||||||
(surface
|
(provider
|
||||||
? buildGroupDisplayName({
|
? buildGroupDisplayName({
|
||||||
surface,
|
provider,
|
||||||
subject,
|
subject,
|
||||||
room,
|
room,
|
||||||
space,
|
space,
|
||||||
@ -272,7 +430,7 @@ export function listSessionsFromStore(params: {
|
|||||||
key,
|
key,
|
||||||
kind: classifySessionKey(key, entry),
|
kind: classifySessionKey(key, entry),
|
||||||
displayName,
|
displayName,
|
||||||
surface,
|
provider,
|
||||||
subject,
|
subject,
|
||||||
room,
|
room,
|
||||||
space,
|
space,
|
||||||
@ -290,8 +448,9 @@ export function listSessionsFromStore(params: {
|
|||||||
totalTokens: total,
|
totalTokens: total,
|
||||||
model: entry?.model,
|
model: entry?.model,
|
||||||
contextTokens: entry?.contextTokens,
|
contextTokens: entry?.contextTokens,
|
||||||
lastChannel: entry?.lastChannel,
|
lastProvider: entry?.lastProvider,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
|
lastAccountId: entry?.lastAccountId,
|
||||||
} satisfies GatewaySessionRow;
|
} satisfies GatewaySessionRow;
|
||||||
})
|
})
|
||||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||||
|
|||||||
@ -407,7 +407,7 @@ describe("monitorIMessageProvider", () => {
|
|||||||
|
|
||||||
expect(updateLastRouteMock).toHaveBeenCalledWith(
|
expect(updateLastRouteMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
channel: "imessage",
|
provider: "imessage",
|
||||||
to: "chat_id:7",
|
to: "chat_id:7",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user