Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
738b3592cd | ||
|
|
cd2af64860 | ||
|
|
04f1e767b2 | ||
|
|
1c7ac2a6ab | ||
|
|
77cf40da87 | ||
|
|
1fe8df85cb | ||
|
|
139f80a291 | ||
|
|
2d066b8715 | ||
|
|
757243993c | ||
|
|
4fb114dcb3 | ||
|
|
612cdac4c3 | ||
|
|
57c66fe813 | ||
|
|
9c02ea9098 | ||
|
|
042b65dfcc | ||
|
|
f6a72ef3c2 | ||
|
|
568cc368ae | ||
|
|
5abe3c2145 | ||
|
|
0e76d21f11 | ||
|
|
5e8693bc42 | ||
|
|
ef78b198cb | ||
|
|
5fdaef3646 | ||
|
|
fa4670c5fe | ||
|
|
c4402a1ce5 | ||
|
|
edd8c613d6 | ||
|
|
0a7f5bf6a5 | ||
|
|
eb3e865f15 | ||
|
|
1baa55c145 | ||
|
|
2e0325e3bf | ||
|
|
dcadaad228 | ||
|
|
1533868680 | ||
|
|
f275cc180b | ||
|
|
31d3aef8d6 | ||
|
|
bd467ff765 | ||
|
|
429f973280 | ||
|
|
6320f739d4 | ||
|
|
1732932c57 | ||
|
|
574b6ab5b1 | ||
|
|
1c737f88fe | ||
|
|
fa8d9b9189 | ||
|
|
512dbedee3 | ||
|
|
db21c2d397 | ||
|
|
1078d178d7 | ||
|
|
a6e780b2f6 | ||
|
|
4e48d0a431 | ||
|
|
c289a88f50 | ||
|
|
4ce495cb2c | ||
|
|
6f5fc2276a |
No files matched your search
+20
-2
@@ -1,16 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.15
|
||||
## 2026.1.14 (unreleased)
|
||||
|
||||
### Changes
|
||||
- Security: add `clawdbot security audit` (`--deep`) and surface it in `status --all` and `doctor`.
|
||||
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
|
||||
- Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.
|
||||
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
|
||||
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
||||
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`.
|
||||
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks).
|
||||
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
||||
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
||||
- Docs: expand gateway security hardening guidance and incident response checklist.
|
||||
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
|
||||
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
|
||||
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
|
||||
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
|
||||
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
|
||||
|
||||
### Fixes
|
||||
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
|
||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
||||
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
|
||||
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
|
||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
||||
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
|
||||
@@ -19,6 +33,8 @@
|
||||
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
|
||||
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
|
||||
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
|
||||
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
|
||||
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
|
||||
|
||||
## 2026.1.14
|
||||
|
||||
@@ -77,6 +93,7 @@
|
||||
- Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default.
|
||||
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
|
||||
- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`. (#790) — thanks @akonyer.
|
||||
- Tools: add provider/model-specific tool policy overrides (`tools.byProvider`) to trim tool exposure per provider.
|
||||
- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades. (#793) — thanks @hsrvc; (#444) — thanks @grp06.
|
||||
- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides. (#740) — thanks @jeffersonwarrior.
|
||||
- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`. (#726) — thanks @FrieSei; (#791) — thanks @roshanasingh4.
|
||||
@@ -89,6 +106,7 @@
|
||||
|
||||
### Fixes
|
||||
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
|
||||
- Tools: apply global tool allow/deny even when agent-specific tool policy is set.
|
||||
- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles. (#822) — thanks @sebslight; (#705) — thanks @TAGOOZ.
|
||||
- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax.
|
||||
- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors. (#795) — thanks @thewilloftheshadow; (#783) — thanks @ananth-vardhan-cn; (#793) — thanks @hsrvc; (#805) — thanks @marcmarg.
|
||||
|
||||
+1
-1
Submodule Peekaboo updated: c1243a7978...95ad7532c1.
@@ -34,8 +34,7 @@ extension AttributedString {
|
||||
var ranges: [Range<AttributedString.Index>] = []
|
||||
for wordRange in wordRanges {
|
||||
if let lastRange = ranges.last,
|
||||
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
|
||||
{
|
||||
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength {
|
||||
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
|
||||
} else {
|
||||
ranges.append(wordRange)
|
||||
|
||||
@@ -13,8 +13,7 @@ public actor TranscriptsStore {
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
fileURL = dir.appendingPathComponent("transcripts.log")
|
||||
if let data = try? Data(contentsOf: fileURL),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
{
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
entries = text.split(separator: "\n").map(String.init).suffix(limit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ public struct WakeWordGateConfig: Sendable, Equatable {
|
||||
public init(
|
||||
triggers: [String],
|
||||
minPostTriggerGap: TimeInterval = 0.45,
|
||||
minCommandLength: Int = 1)
|
||||
{
|
||||
minCommandLength: Int = 1) {
|
||||
self.triggers = triggers
|
||||
self.minPostTriggerGap = minPostTriggerGap
|
||||
self.minCommandLength = minCommandLength
|
||||
@@ -57,6 +56,12 @@ public enum WakeWordGate {
|
||||
let tokens: [String]
|
||||
}
|
||||
|
||||
private struct MatchCandidate {
|
||||
let index: Int
|
||||
let triggerEnd: TimeInterval
|
||||
let gap: TimeInterval
|
||||
}
|
||||
|
||||
public static func match(
|
||||
transcript: String,
|
||||
segments: [WakeWordSegment],
|
||||
@@ -68,7 +73,7 @@ public enum WakeWordGate {
|
||||
let tokens = normalizeSegments(segments)
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
|
||||
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
|
||||
var best: MatchCandidate?
|
||||
|
||||
for trigger in triggerTokens {
|
||||
let count = trigger.tokens.count
|
||||
@@ -84,7 +89,7 @@ public enum WakeWordGate {
|
||||
|
||||
if let best, i <= best.index { continue }
|
||||
|
||||
best = (i, triggerEnd, gap)
|
||||
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ enum CLIRegistry {
|
||||
subcommands: [
|
||||
descriptor(for: ServiceInstall.self),
|
||||
descriptor(for: ServiceUninstall.self),
|
||||
descriptor(for: ServiceStatus.self),
|
||||
descriptor(for: ServiceStatus.self)
|
||||
])
|
||||
let doctorDesc = descriptor(for: DoctorCommand.self)
|
||||
let setupDesc = descriptor(for: SetupCommand.self)
|
||||
@@ -54,7 +54,7 @@ enum CLIRegistry {
|
||||
startDesc,
|
||||
stopDesc,
|
||||
restartDesc,
|
||||
statusDesc,
|
||||
statusDesc
|
||||
])
|
||||
return [root]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ private enum LaunchdHelper {
|
||||
"Label": label,
|
||||
"ProgramArguments": [executable, "serve"],
|
||||
"RunAtLoad": true,
|
||||
"KeepAlive": true,
|
||||
"KeepAlive": true
|
||||
]
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
try data.write(to: plistURL)
|
||||
|
||||
@@ -25,78 +25,123 @@ private func dispatch(invocation: CommandInvocation) async throws {
|
||||
|
||||
switch first {
|
||||
case "swabble":
|
||||
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
|
||||
let sub = path[1]
|
||||
switch sub {
|
||||
case "serve":
|
||||
var cmd = ServeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "transcribe":
|
||||
var cmd = TranscribeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "test-hook":
|
||||
var cmd = TestHookCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "mic":
|
||||
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
|
||||
let micSub = path[2]
|
||||
if micSub == "list" {
|
||||
var cmd = MicList(parsed: parsed)
|
||||
try await cmd.run()
|
||||
} else if micSub == "set" {
|
||||
var cmd = MicSet(parsed: parsed)
|
||||
try await cmd.run()
|
||||
} else {
|
||||
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
||||
}
|
||||
case "service":
|
||||
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
|
||||
let svcSub = path[2]
|
||||
switch svcSub {
|
||||
case "install":
|
||||
var cmd = ServiceInstall()
|
||||
try await cmd.run()
|
||||
case "uninstall":
|
||||
var cmd = ServiceUninstall()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = ServiceStatus()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
||||
}
|
||||
case "doctor":
|
||||
var cmd = DoctorCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "setup":
|
||||
var cmd = SetupCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "health":
|
||||
var cmd = HealthCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "tail-log":
|
||||
var cmd = TailLogCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "start":
|
||||
var cmd = StartCommand()
|
||||
try await cmd.run()
|
||||
case "stop":
|
||||
var cmd = StopCommand()
|
||||
try await cmd.run()
|
||||
case "restart":
|
||||
var cmd = RestartCommand()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = StatusCommand()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
||||
}
|
||||
try await dispatchSwabble(parsed: parsed, path: path)
|
||||
default:
|
||||
throw CommanderProgramError.unknownCommand(first)
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws {
|
||||
let sub = try subcommand(path, index: 1, command: "swabble")
|
||||
switch sub {
|
||||
case "mic":
|
||||
try await dispatchMic(parsed: parsed, path: path)
|
||||
case "service":
|
||||
try await dispatchService(path: path)
|
||||
default:
|
||||
let handlers = swabbleHandlers(parsed: parsed)
|
||||
guard let handler = handlers[sub] else {
|
||||
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
||||
}
|
||||
try await handler()
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] {
|
||||
[
|
||||
"serve": {
|
||||
var cmd = ServeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"transcribe": {
|
||||
var cmd = TranscribeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"test-hook": {
|
||||
var cmd = TestHookCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"doctor": {
|
||||
var cmd = DoctorCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"setup": {
|
||||
var cmd = SetupCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"health": {
|
||||
var cmd = HealthCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"tail-log": {
|
||||
var cmd = TailLogCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"start": {
|
||||
var cmd = StartCommand()
|
||||
try await cmd.run()
|
||||
},
|
||||
"stop": {
|
||||
var cmd = StopCommand()
|
||||
try await cmd.run()
|
||||
},
|
||||
"restart": {
|
||||
var cmd = RestartCommand()
|
||||
try await cmd.run()
|
||||
},
|
||||
"status": {
|
||||
var cmd = StatusCommand()
|
||||
try await cmd.run()
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatchMic(parsed: ParsedValues, path: [String]) async throws {
|
||||
let micSub = try subcommand(path, index: 2, command: "mic")
|
||||
switch micSub {
|
||||
case "list":
|
||||
var cmd = MicList(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "set":
|
||||
var cmd = MicSet(parsed: parsed)
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatchService(path: [String]) async throws {
|
||||
let svcSub = try subcommand(path, index: 2, command: "service")
|
||||
switch svcSub {
|
||||
case "install":
|
||||
var cmd = ServiceInstall()
|
||||
try await cmd.run()
|
||||
case "uninstall":
|
||||
var cmd = ServiceUninstall()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = ServiceStatus()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
||||
}
|
||||
}
|
||||
|
||||
private func subcommand(_ path: [String], index: Int, command: String) throws -> String {
|
||||
guard path.count > index else {
|
||||
throw CommanderProgramError.missingSubcommand(command: command)
|
||||
}
|
||||
return path[index]
|
||||
}
|
||||
|
||||
if #available(macOS 26.0, *) {
|
||||
let exitCode = await runCLI()
|
||||
exit(exitCode)
|
||||
|
||||
@@ -19,6 +19,7 @@ enum ConfigStore {
|
||||
}
|
||||
|
||||
private static let overrideStore = OverrideStore()
|
||||
@MainActor private static var lastHash: String?
|
||||
|
||||
private static func isRemoteMode() async -> Bool {
|
||||
let overrides = await self.overrideStore.overrides
|
||||
@@ -75,6 +76,7 @@ enum ConfigStore {
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 8000)
|
||||
self.lastHash = snap.hash
|
||||
return snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
} catch {
|
||||
return nil
|
||||
@@ -83,17 +85,24 @@ enum ConfigStore {
|
||||
|
||||
@MainActor
|
||||
private static func saveToGateway(_ root: [String: Any]) async throws {
|
||||
if self.lastHash == nil {
|
||||
_ = await self.loadFromGateway()
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode config.",
|
||||
])
|
||||
}
|
||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
var params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
if let baseHash = self.lastHash {
|
||||
params["baseHash"] = AnyCodable(baseHash)
|
||||
}
|
||||
_ = try await GatewayConnection.shared.requestRaw(
|
||||
method: .configSet,
|
||||
params: params,
|
||||
timeoutMs: 10000)
|
||||
_ = await self.loadFromGateway()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -12,6 +12,7 @@ extension ConnectionsStore {
|
||||
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
|
||||
: nil
|
||||
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
self.configHash = snap.hash
|
||||
self.configLoaded = true
|
||||
|
||||
self.applyUIConfig(snap)
|
||||
@@ -34,10 +35,22 @@ extension ConnectionsStore {
|
||||
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||
}
|
||||
|
||||
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
|
||||
if let channels = snap.config?["channels"]?.dictionaryValue,
|
||||
let entry = channels[key]?.dictionaryValue {
|
||||
return entry
|
||||
}
|
||||
return snap.config?[key]?.dictionaryValue
|
||||
}
|
||||
|
||||
private func applyTelegramConfig(_ snap: ConfigSnapshot) {
|
||||
let telegram = snap.config?["telegram"]?.dictionaryValue
|
||||
let telegram = self.resolveChannelConfig(snap, key: "telegram")
|
||||
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
|
||||
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
|
||||
let groups = telegram?["groups"]?.dictionaryValue
|
||||
let defaultGroup = groups?["*"]?.dictionaryValue
|
||||
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
|
||||
?? telegram?["requireMention"]?.boolValue
|
||||
?? true
|
||||
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
|
||||
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
|
||||
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
|
||||
@@ -46,7 +59,7 @@ extension ConnectionsStore {
|
||||
}
|
||||
|
||||
private func applyDiscordConfig(_ snap: ConfigSnapshot) {
|
||||
let discord = snap.config?["discord"]?.dictionaryValue
|
||||
let discord = self.resolveChannelConfig(snap, key: "discord")
|
||||
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
|
||||
self.discordToken = discord?["token"]?.stringValue ?? ""
|
||||
|
||||
@@ -122,7 +135,7 @@ extension ConnectionsStore {
|
||||
}
|
||||
|
||||
private func applySignalConfig(_ snap: ConfigSnapshot) {
|
||||
let signal = snap.config?["signal"]?.dictionaryValue
|
||||
let signal = self.resolveChannelConfig(snap, key: "signal")
|
||||
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
|
||||
self.signalAccount = signal?["account"]?.stringValue ?? ""
|
||||
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
|
||||
@@ -139,7 +152,7 @@ extension ConnectionsStore {
|
||||
}
|
||||
|
||||
private func applyIMessageConfig(_ snap: ConfigSnapshot) {
|
||||
let imessage = snap.config?["imessage"]?.dictionaryValue
|
||||
let imessage = self.resolveChannelConfig(snap, key: "imessage")
|
||||
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
|
||||
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
|
||||
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
|
||||
@@ -150,6 +163,15 @@ extension ConnectionsStore {
|
||||
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
|
||||
}
|
||||
|
||||
private func channelConfigRoot(for key: String) -> [String: Any] {
|
||||
if let channels = self.configRoot["channels"] as? [String: Any],
|
||||
let entry = channels[key] as? [String: Any]
|
||||
{
|
||||
return entry
|
||||
}
|
||||
return self.configRoot[key] as? [String: Any] ?? [:]
|
||||
}
|
||||
|
||||
func saveTelegramConfig() async {
|
||||
guard !self.isSavingConfig else { return }
|
||||
self.isSavingConfig = true
|
||||
@@ -158,30 +180,24 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var telegram: [String: Any] = (self.configRoot["telegram"] as? [String: Any]) ?? [:]
|
||||
let token = self.trimmed(self.telegramToken)
|
||||
if token.isEmpty {
|
||||
telegram.removeValue(forKey: "botToken")
|
||||
} else {
|
||||
telegram["botToken"] = token
|
||||
var telegram: [String: Any] = [:]
|
||||
if !self.isTelegramTokenLocked {
|
||||
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
|
||||
}
|
||||
|
||||
telegram["requireMention"] = self.telegramRequireMention
|
||||
|
||||
telegram["requireMention"] = NSNull()
|
||||
telegram["groups"] = [
|
||||
"*": [
|
||||
"requireMention": self.telegramRequireMention,
|
||||
],
|
||||
]
|
||||
let allow = self.splitCsv(self.telegramAllowFrom)
|
||||
if allow.isEmpty {
|
||||
telegram.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
telegram["allowFrom"] = allow
|
||||
}
|
||||
self.setPatchList(&telegram, key: "allowFrom", values: allow)
|
||||
self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
|
||||
self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
|
||||
self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
|
||||
self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
|
||||
|
||||
self.setOptionalString(&telegram, key: "proxy", value: self.telegramProxy)
|
||||
self.setOptionalString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
|
||||
self.setOptionalString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
|
||||
self.setOptionalString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
|
||||
|
||||
self.setSection("telegram", payload: telegram)
|
||||
await self.persistConfig()
|
||||
await self.persistChannelPatch("telegram", payload: telegram)
|
||||
}
|
||||
|
||||
func saveDiscordConfig() async {
|
||||
@@ -192,9 +208,9 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
let discord = self.buildDiscordConfig()
|
||||
self.setSection("discord", payload: discord)
|
||||
await self.persistConfig()
|
||||
let base = self.channelConfigRoot(for: "discord")
|
||||
let discord = self.buildDiscordPatch(base: base)
|
||||
await self.persistChannelPatch("discord", payload: discord)
|
||||
}
|
||||
|
||||
func saveSignalConfig() async {
|
||||
@@ -205,42 +221,23 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var signal: [String: Any] = (self.configRoot["signal"] as? [String: Any]) ?? [:]
|
||||
if self.signalEnabled {
|
||||
signal.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
signal["enabled"] = false
|
||||
}
|
||||
|
||||
self.setOptionalString(&signal, key: "account", value: self.signalAccount)
|
||||
self.setOptionalString(&signal, key: "httpUrl", value: self.signalHttpUrl)
|
||||
self.setOptionalString(&signal, key: "httpHost", value: self.signalHttpHost)
|
||||
self.setOptionalNumber(&signal, key: "httpPort", value: self.signalHttpPort)
|
||||
self.setOptionalString(&signal, key: "cliPath", value: self.signalCliPath)
|
||||
|
||||
if self.signalAutoStart {
|
||||
signal.removeValue(forKey: "autoStart")
|
||||
} else {
|
||||
signal["autoStart"] = false
|
||||
}
|
||||
|
||||
self.setOptionalString(&signal, key: "receiveMode", value: self.signalReceiveMode)
|
||||
|
||||
self.setOptionalBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments)
|
||||
self.setOptionalBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories)
|
||||
self.setOptionalBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts)
|
||||
|
||||
var signal: [String: Any] = [:]
|
||||
self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
|
||||
self.setPatchString(&signal, key: "account", value: self.signalAccount)
|
||||
self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
|
||||
self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost)
|
||||
self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort)
|
||||
self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath)
|
||||
self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
|
||||
self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
|
||||
self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
|
||||
self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
|
||||
self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
|
||||
let allow = self.splitCsv(self.signalAllowFrom)
|
||||
if allow.isEmpty {
|
||||
signal.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
signal["allowFrom"] = allow
|
||||
}
|
||||
self.setPatchList(&signal, key: "allowFrom", values: allow)
|
||||
self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
|
||||
|
||||
self.setOptionalNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
|
||||
|
||||
self.setSection("signal", payload: signal)
|
||||
await self.persistConfig()
|
||||
await self.persistChannelPatch("signal", payload: signal)
|
||||
}
|
||||
|
||||
func saveIMessageConfig() async {
|
||||
@@ -251,147 +248,168 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var imessage: [String: Any] = (self.configRoot["imessage"] as? [String: Any]) ?? [:]
|
||||
if self.imessageEnabled {
|
||||
imessage.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
imessage["enabled"] = false
|
||||
}
|
||||
|
||||
self.setOptionalString(&imessage, key: "cliPath", value: self.imessageCliPath)
|
||||
self.setOptionalString(&imessage, key: "dbPath", value: self.imessageDbPath)
|
||||
var imessage: [String: Any] = [:]
|
||||
self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
|
||||
self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
|
||||
self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
|
||||
|
||||
let service = self.trimmed(self.imessageService)
|
||||
if service.isEmpty || service == "auto" {
|
||||
imessage.removeValue(forKey: "service")
|
||||
imessage["service"] = NSNull()
|
||||
} else {
|
||||
imessage["service"] = service
|
||||
}
|
||||
|
||||
self.setOptionalString(&imessage, key: "region", value: self.imessageRegion)
|
||||
self.setPatchString(&imessage, key: "region", value: self.imessageRegion)
|
||||
|
||||
let allow = self.splitCsv(self.imessageAllowFrom)
|
||||
if allow.isEmpty {
|
||||
imessage.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
imessage["allowFrom"] = allow
|
||||
}
|
||||
self.setPatchList(&imessage, key: "allowFrom", values: allow)
|
||||
|
||||
self.setOptionalBool(&imessage, key: "includeAttachments", value: self.imessageIncludeAttachments)
|
||||
self.setOptionalNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
|
||||
self.setPatchBool(
|
||||
&imessage,
|
||||
key: "includeAttachments",
|
||||
value: self.imessageIncludeAttachments,
|
||||
defaultValue: false)
|
||||
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
|
||||
|
||||
self.setSection("imessage", payload: imessage)
|
||||
await self.persistConfig()
|
||||
await self.persistChannelPatch("imessage", payload: imessage)
|
||||
}
|
||||
|
||||
private func buildDiscordConfig() -> [String: Any] {
|
||||
var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:]
|
||||
if self.discordEnabled {
|
||||
discord.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
discord["enabled"] = false
|
||||
private func buildDiscordPatch(base: [String: Any]) -> [String: Any] {
|
||||
var discord: [String: Any] = [:]
|
||||
self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
|
||||
if !self.isDiscordTokenLocked {
|
||||
self.setPatchString(&discord, key: "token", value: self.discordToken)
|
||||
}
|
||||
self.setOptionalString(&discord, key: "token", value: self.discordToken)
|
||||
|
||||
if let dm = self.buildDiscordDmConfig(base: discord["dm"] as? [String: Any] ?? [:]) {
|
||||
if let dm = self.buildDiscordDmPatch() {
|
||||
discord["dm"] = dm
|
||||
} else {
|
||||
discord.removeValue(forKey: "dm")
|
||||
discord["dm"] = NSNull()
|
||||
}
|
||||
|
||||
self.setOptionalNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
|
||||
self.setOptionalInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
|
||||
self.setOptionalInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
|
||||
self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
|
||||
self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
|
||||
self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
|
||||
|
||||
let replyToMode = self.trimmed(self.discordReplyToMode)
|
||||
if replyToMode.isEmpty || replyToMode == "off" {
|
||||
discord.removeValue(forKey: "replyToMode")
|
||||
} else if ["first", "all"].contains(replyToMode) {
|
||||
discord["replyToMode"] = replyToMode
|
||||
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
|
||||
discord["replyToMode"] = NSNull()
|
||||
} else {
|
||||
discord.removeValue(forKey: "replyToMode")
|
||||
discord["replyToMode"] = replyToMode
|
||||
}
|
||||
|
||||
if let guilds = self.buildDiscordGuildsConfig() {
|
||||
let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
|
||||
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
|
||||
discord["guilds"] = guilds
|
||||
} else {
|
||||
discord.removeValue(forKey: "guilds")
|
||||
discord["guilds"] = NSNull()
|
||||
}
|
||||
|
||||
if let actions = self.buildDiscordActionsConfig(base: discord["actions"] as? [String: Any] ?? [:]) {
|
||||
if let actions = self.buildDiscordActionsPatch() {
|
||||
discord["actions"] = actions
|
||||
} else {
|
||||
discord.removeValue(forKey: "actions")
|
||||
discord["actions"] = NSNull()
|
||||
}
|
||||
|
||||
if let slash = self.buildDiscordSlashConfig(base: discord["slashCommand"] as? [String: Any] ?? [:]) {
|
||||
if let slash = self.buildDiscordSlashPatch() {
|
||||
discord["slashCommand"] = slash
|
||||
} else {
|
||||
discord.removeValue(forKey: "slashCommand")
|
||||
discord["slashCommand"] = NSNull()
|
||||
}
|
||||
|
||||
return discord
|
||||
}
|
||||
|
||||
private func buildDiscordDmConfig(base: [String: Any]) -> [String: Any]? {
|
||||
var dm = base
|
||||
if self.discordDmEnabled {
|
||||
dm.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
dm["enabled"] = false
|
||||
}
|
||||
private func buildDiscordDmPatch() -> [String: Any]? {
|
||||
var dm: [String: Any] = [:]
|
||||
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
|
||||
let allow = self.splitCsv(self.discordAllowFrom)
|
||||
if allow.isEmpty {
|
||||
dm.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
dm["allowFrom"] = allow
|
||||
}
|
||||
|
||||
if self.discordGroupEnabled {
|
||||
dm["groupEnabled"] = true
|
||||
} else {
|
||||
dm.removeValue(forKey: "groupEnabled")
|
||||
}
|
||||
|
||||
self.setPatchList(&dm, key: "allowFrom", values: allow)
|
||||
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
|
||||
let groupChannels = self.splitCsv(self.discordGroupChannels)
|
||||
if groupChannels.isEmpty {
|
||||
dm.removeValue(forKey: "groupChannels")
|
||||
} else {
|
||||
dm["groupChannels"] = groupChannels
|
||||
}
|
||||
|
||||
self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
|
||||
return dm.isEmpty ? nil : dm
|
||||
}
|
||||
|
||||
private func buildDiscordGuildsConfig() -> [String: Any]? {
|
||||
let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in
|
||||
let key = self.trimmed(entry.key)
|
||||
guard !key.isEmpty else { return }
|
||||
var payload: [String: Any] = [:]
|
||||
let slug = self.trimmed(entry.slug)
|
||||
if !slug.isEmpty { payload["slug"] = slug }
|
||||
if entry.requireMention { payload["requireMention"] = true }
|
||||
if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) {
|
||||
payload["reactionNotifications"] = entry.reactionNotifications
|
||||
}
|
||||
let users = self.splitCsv(entry.users)
|
||||
if !users.isEmpty { payload["users"] = users }
|
||||
let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in
|
||||
let channelKey = self.trimmed(channel.key)
|
||||
guard !channelKey.isEmpty else { return }
|
||||
var channelPayload: [String: Any] = [:]
|
||||
if !channel.allow { channelPayload["allow"] = false }
|
||||
if channel.requireMention { channelPayload["requireMention"] = true }
|
||||
channelsResult[channelKey] = channelPayload
|
||||
}
|
||||
if !channels.isEmpty { payload["channels"] = channels }
|
||||
result[key] = payload
|
||||
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
|
||||
if self.discordGuilds.isEmpty {
|
||||
return NSNull()
|
||||
}
|
||||
return guilds.isEmpty ? nil : guilds
|
||||
var patch: [String: Any] = [:]
|
||||
let baseKeys = Set(base.keys)
|
||||
var formKeys = Set<String>()
|
||||
for entry in self.discordGuilds {
|
||||
let key = self.trimmed(entry.key)
|
||||
guard !key.isEmpty else { continue }
|
||||
formKeys.insert(key)
|
||||
let baseGuild = base[key] as? [String: Any] ?? [:]
|
||||
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild)
|
||||
}
|
||||
for key in baseKeys.subtracting(formKeys) {
|
||||
patch[key] = NSNull()
|
||||
}
|
||||
return patch.isEmpty ? NSNull() : patch
|
||||
}
|
||||
|
||||
private func buildDiscordActionsConfig(base: [String: Any]) -> [String: Any]? {
|
||||
var actions = base
|
||||
private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
|
||||
var payload: [String: Any] = [:]
|
||||
let slug = self.trimmed(entry.slug)
|
||||
if slug.isEmpty {
|
||||
payload["slug"] = NSNull()
|
||||
} else {
|
||||
payload["slug"] = slug
|
||||
}
|
||||
if entry.requireMention {
|
||||
payload["requireMention"] = true
|
||||
} else {
|
||||
payload["requireMention"] = NSNull()
|
||||
}
|
||||
if ["off", "all", "allowlist"].contains(entry.reactionNotifications) {
|
||||
payload["reactionNotifications"] = entry.reactionNotifications
|
||||
} else {
|
||||
payload["reactionNotifications"] = NSNull()
|
||||
}
|
||||
let users = self.splitCsv(entry.users)
|
||||
self.setPatchList(&payload, key: "users", values: users)
|
||||
|
||||
let baseChannels = base["channels"] as? [String: Any] ?? [:]
|
||||
if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) {
|
||||
payload["channels"] = channels
|
||||
} else {
|
||||
payload["channels"] = NSNull()
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? {
|
||||
if forms.isEmpty {
|
||||
return NSNull()
|
||||
}
|
||||
var patch: [String: Any] = [:]
|
||||
let baseKeys = Set(base.keys)
|
||||
var formKeys = Set<String>()
|
||||
for channel in forms {
|
||||
let channelKey = self.trimmed(channel.key)
|
||||
guard !channelKey.isEmpty else { continue }
|
||||
formKeys.insert(channelKey)
|
||||
var channelPayload: [String: Any] = [:]
|
||||
self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true)
|
||||
self.setPatchBool(
|
||||
&channelPayload,
|
||||
key: "requireMention",
|
||||
value: channel.requireMention,
|
||||
defaultValue: false)
|
||||
patch[channelKey] = channelPayload
|
||||
}
|
||||
for key in baseKeys.subtracting(formKeys) {
|
||||
patch[key] = NSNull()
|
||||
}
|
||||
return patch.isEmpty ? NSNull() : patch
|
||||
}
|
||||
|
||||
private func buildDiscordActionsPatch() -> [String: Any]? {
|
||||
var actions: [String: Any] = [:]
|
||||
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
|
||||
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
|
||||
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
|
||||
@@ -410,52 +428,44 @@ extension ConnectionsStore {
|
||||
return actions.isEmpty ? nil : actions
|
||||
}
|
||||
|
||||
private func buildDiscordSlashConfig(base: [String: Any]) -> [String: Any]? {
|
||||
var slash = base
|
||||
if self.discordSlashEnabled {
|
||||
slash["enabled"] = true
|
||||
} else {
|
||||
slash.removeValue(forKey: "enabled")
|
||||
}
|
||||
self.setOptionalString(&slash, key: "name", value: self.discordSlashName)
|
||||
self.setOptionalString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
|
||||
if self.discordSlashEphemeral {
|
||||
slash.removeValue(forKey: "ephemeral")
|
||||
} else {
|
||||
slash["ephemeral"] = false
|
||||
}
|
||||
private func buildDiscordSlashPatch() -> [String: Any]? {
|
||||
var slash: [String: Any] = [:]
|
||||
self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
|
||||
self.setPatchString(&slash, key: "name", value: self.discordSlashName)
|
||||
self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
|
||||
self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
|
||||
return slash.isEmpty ? nil : slash
|
||||
}
|
||||
|
||||
private func persistConfig() async {
|
||||
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
|
||||
do {
|
||||
guard let baseHash = self.configHash else {
|
||||
self.configStatus = "Config hash missing; reload and retry."
|
||||
return
|
||||
}
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: self.configRoot,
|
||||
withJSONObject: ["channels": [channelId: payload]],
|
||||
options: [.prettyPrinted, .sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
self.configStatus = "Failed to encode config."
|
||||
return
|
||||
}
|
||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
let params: [String: AnyCodable] = [
|
||||
"raw": AnyCodable(raw),
|
||||
"baseHash": AnyCodable(baseHash),
|
||||
]
|
||||
_ = try await GatewayConnection.shared.requestRaw(
|
||||
method: .configSet,
|
||||
method: .configPatch,
|
||||
params: params,
|
||||
timeoutMs: 10000)
|
||||
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
|
||||
await self.loadConfig()
|
||||
await self.refresh(probe: true)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func setSection(_ key: String, payload: [String: Any]) {
|
||||
if payload.isEmpty {
|
||||
self.configRoot.removeValue(forKey: key)
|
||||
} else {
|
||||
self.configRoot[key] = payload
|
||||
}
|
||||
}
|
||||
|
||||
private func stringList(from values: [AnyCodable]?) -> String {
|
||||
guard let values else { return "" }
|
||||
let strings = values.compactMap { entry -> String? in
|
||||
@@ -492,25 +502,29 @@ extension ConnectionsStore {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func setOptionalString(_ target: inout [String: Any], key: String, value: String) {
|
||||
private func setPatchString(_ target: inout [String: Any], key: String, value: String) {
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target[key] = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private func setOptionalNumber(_ target: inout [String: Any], key: String, value: String) {
|
||||
private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) {
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target.removeValue(forKey: key)
|
||||
} else if let number = Double(trimmed) {
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
if let number = Double(trimmed) {
|
||||
target[key] = number
|
||||
} else {
|
||||
target[key] = NSNull()
|
||||
}
|
||||
}
|
||||
|
||||
private func setOptionalInt(
|
||||
private func setPatchInt(
|
||||
_ target: inout [String: Any],
|
||||
key: String,
|
||||
value: String,
|
||||
@@ -518,26 +532,39 @@ extension ConnectionsStore {
|
||||
{
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
guard let number = Int(trimmed) else {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
let isValid = allowZero ? number >= 0 : number > 0
|
||||
guard isValid else {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
target[key] = number
|
||||
}
|
||||
|
||||
private func setOptionalBool(_ target: inout [String: Any], key: String, value: Bool) {
|
||||
if value {
|
||||
target[key] = true
|
||||
private func setPatchBool(
|
||||
_ target: inout [String: Any],
|
||||
key: String,
|
||||
value: Bool,
|
||||
defaultValue: Bool)
|
||||
{
|
||||
if value == defaultValue {
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
|
||||
if values.isEmpty {
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target[key] = values
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,7 +575,7 @@ extension ConnectionsStore {
|
||||
defaultValue: Bool)
|
||||
{
|
||||
if value == defaultValue {
|
||||
actions.removeValue(forKey: key)
|
||||
actions[key] = NSNull()
|
||||
} else {
|
||||
actions[key] = value
|
||||
}
|
||||
|
||||
@@ -180,6 +180,7 @@ struct ConfigSnapshot: Codable {
|
||||
let path: String?
|
||||
let exists: Bool?
|
||||
let raw: String?
|
||||
let hash: String?
|
||||
let parsed: AnyCodable?
|
||||
let valid: Bool?
|
||||
let config: [String: AnyCodable]?
|
||||
@@ -307,6 +308,7 @@ final class ConnectionsStore {
|
||||
var pollTask: Task<Void, Never>?
|
||||
var configRoot: [String: Any] = [:]
|
||||
var configLoaded = false
|
||||
var configHash: String?
|
||||
|
||||
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||
self.isPreview = isPreview
|
||||
|
||||
@@ -55,6 +55,7 @@ actor GatewayConnection {
|
||||
case channelsStatus = "channels.status"
|
||||
case configGet = "config.get"
|
||||
case configSet = "config.set"
|
||||
case configPatch = "config.patch"
|
||||
case wizardStart = "wizard.start"
|
||||
case wizardNext = "wizard.next"
|
||||
case wizardCancel = "wizard.cancel"
|
||||
|
||||
@@ -845,42 +845,67 @@ public struct ConfigGetParams: Codable, Sendable {
|
||||
|
||||
public struct ConfigSetParams: Codable, Sendable {
|
||||
public let raw: String
|
||||
public let basehash: String?
|
||||
|
||||
public init(
|
||||
raw: String
|
||||
raw: String,
|
||||
basehash: String?
|
||||
) {
|
||||
self.raw = raw
|
||||
self.basehash = basehash
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case raw
|
||||
case basehash = "baseHash"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigApplyParams: Codable, Sendable {
|
||||
public let raw: String
|
||||
public let basehash: String?
|
||||
public let sessionkey: String?
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
|
||||
public init(
|
||||
raw: String,
|
||||
basehash: String?,
|
||||
sessionkey: String?,
|
||||
note: String?,
|
||||
restartdelayms: Int?
|
||||
) {
|
||||
self.raw = raw
|
||||
self.basehash = basehash
|
||||
self.sessionkey = sessionkey
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case raw
|
||||
case basehash = "baseHash"
|
||||
case sessionkey = "sessionKey"
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigPatchParams: Codable, Sendable {
|
||||
public let raw: String
|
||||
public let basehash: String?
|
||||
|
||||
public init(
|
||||
raw: String,
|
||||
basehash: String?
|
||||
) {
|
||||
self.raw = raw
|
||||
self.basehash = basehash
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case raw
|
||||
case basehash = "baseHash"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigSchemaParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
|
||||
@@ -4,22 +4,22 @@ import Testing
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct ConnectionsSettingsSmokeTests {
|
||||
@Test func connectionsSettingsBuildsBodyWithSnapshot() {
|
||||
let store = ConnectionsStore(isPreview: true)
|
||||
store.snapshot = ChannelsStatusSnapshot(
|
||||
ts: 1_700_000_000_000,
|
||||
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||
channelLabels: [
|
||||
"whatsapp": "WhatsApp",
|
||||
"telegram": "Telegram",
|
||||
"signal": "Signal",
|
||||
"imessage": "iMessage",
|
||||
],
|
||||
channels: [
|
||||
"whatsapp": AnyCodable([
|
||||
"configured": true,
|
||||
"linked": true,
|
||||
struct ConnectionsSettingsSmokeTests {
|
||||
@Test func connectionsSettingsBuildsBodyWithSnapshot() {
|
||||
let store = ConnectionsStore(isPreview: true)
|
||||
store.snapshot = ChannelsStatusSnapshot(
|
||||
ts: 1_700_000_000_000,
|
||||
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||
channelLabels: [
|
||||
"whatsapp": "WhatsApp",
|
||||
"telegram": "Telegram",
|
||||
"signal": "Signal",
|
||||
"imessage": "iMessage",
|
||||
],
|
||||
channels: [
|
||||
"whatsapp": AnyCodable([
|
||||
"configured": true,
|
||||
"linked": true,
|
||||
"authAgeMs": 86_400_000,
|
||||
"self": ["e164": "+15551234567"],
|
||||
"running": true,
|
||||
@@ -70,13 +70,13 @@ import Testing
|
||||
"lastError": "not configured",
|
||||
"probe": ["ok": false, "error": "imsg not found (imsg)"],
|
||||
"lastProbeAt": 1_700_000_050_000,
|
||||
]),
|
||||
],
|
||||
channelAccounts: [:],
|
||||
channelDefaultAccountId: [
|
||||
"whatsapp": "default",
|
||||
"telegram": "default",
|
||||
"signal": "default",
|
||||
]),
|
||||
],
|
||||
channelAccounts: [:],
|
||||
channelDefaultAccountId: [
|
||||
"whatsapp": "default",
|
||||
"telegram": "default",
|
||||
"signal": "default",
|
||||
"imessage": "default",
|
||||
])
|
||||
|
||||
@@ -93,23 +93,23 @@ import Testing
|
||||
|
||||
let view = ConnectionsSettings(store: store)
|
||||
_ = view.body
|
||||
}
|
||||
}
|
||||
|
||||
@Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
|
||||
let store = ConnectionsStore(isPreview: true)
|
||||
store.snapshot = ChannelsStatusSnapshot(
|
||||
ts: 1_700_000_000_000,
|
||||
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||
channelLabels: [
|
||||
"whatsapp": "WhatsApp",
|
||||
"telegram": "Telegram",
|
||||
"signal": "Signal",
|
||||
"imessage": "iMessage",
|
||||
],
|
||||
channels: [
|
||||
"whatsapp": AnyCodable([
|
||||
"configured": false,
|
||||
"linked": false,
|
||||
@Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
|
||||
let store = ConnectionsStore(isPreview: true)
|
||||
store.snapshot = ChannelsStatusSnapshot(
|
||||
ts: 1_700_000_000_000,
|
||||
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||
channelLabels: [
|
||||
"whatsapp": "WhatsApp",
|
||||
"telegram": "Telegram",
|
||||
"signal": "Signal",
|
||||
"imessage": "iMessage",
|
||||
],
|
||||
channels: [
|
||||
"whatsapp": AnyCodable([
|
||||
"configured": false,
|
||||
"linked": false,
|
||||
"running": false,
|
||||
"connected": false,
|
||||
"reconnectAttempts": 0,
|
||||
@@ -146,13 +146,13 @@ import Testing
|
||||
"cliPath": "imsg",
|
||||
"probe": ["ok": false, "error": "imsg not found (imsg)"],
|
||||
"lastProbeAt": 1_700_000_200_000,
|
||||
]),
|
||||
],
|
||||
channelAccounts: [:],
|
||||
channelDefaultAccountId: [
|
||||
"whatsapp": "default",
|
||||
"telegram": "default",
|
||||
"signal": "default",
|
||||
]),
|
||||
],
|
||||
channelAccounts: [:],
|
||||
channelDefaultAccountId: [
|
||||
"whatsapp": "default",
|
||||
"telegram": "default",
|
||||
"signal": "default",
|
||||
"imessage": "default",
|
||||
])
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct HealthDecodeTests {
|
||||
private let sampleJSON: String = // minimal but complete payload
|
||||
"""
|
||||
@Suite struct HealthDecodeTests {
|
||||
private let sampleJSON: String = // minimal but complete payload
|
||||
"""
|
||||
{"ts":1733622000,"durationMs":420,"channels":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"channelOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}}
|
||||
"""
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Clawdbot Chrome Extension (Browser Relay)
|
||||
|
||||
Purpose: attach Clawdbot to an existing Chrome tab so the Gateway can automate it (via the local CDP relay server).
|
||||
|
||||
## Dev / load unpacked
|
||||
|
||||
1. Build/run Clawdbot Gateway with browser control enabled.
|
||||
2. Ensure the relay server is reachable at `http://127.0.0.1:18792/` (default).
|
||||
3. Install the extension to a stable path:
|
||||
|
||||
```bash
|
||||
clawdbot browser extension install
|
||||
clawdbot browser extension path
|
||||
```
|
||||
|
||||
4. Chrome → `chrome://extensions` → enable “Developer mode”.
|
||||
5. “Load unpacked” → select the path printed above.
|
||||
6. Pin the extension. Click the icon on a tab to attach/detach.
|
||||
|
||||
## Options
|
||||
|
||||
- `Relay port`: defaults to `18792`.
|
||||
@@ -0,0 +1,438 @@
|
||||
const DEFAULT_PORT = 18792
|
||||
|
||||
const BADGE = {
|
||||
on: { text: 'ON', color: '#FF5A36' },
|
||||
off: { text: '', color: '#000000' },
|
||||
connecting: { text: '…', color: '#F59E0B' },
|
||||
error: { text: '!', color: '#B91C1C' },
|
||||
}
|
||||
|
||||
/** @type {WebSocket|null} */
|
||||
let relayWs = null
|
||||
/** @type {Promise<void>|null} */
|
||||
let relayConnectPromise = null
|
||||
|
||||
let debuggerListenersInstalled = false
|
||||
|
||||
let nextSession = 1
|
||||
|
||||
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number}>} */
|
||||
const tabs = new Map()
|
||||
/** @type {Map<string, number>} */
|
||||
const tabBySession = new Map()
|
||||
/** @type {Map<string, number>} */
|
||||
const childSessionToTab = new Map()
|
||||
|
||||
/** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
|
||||
const pending = new Map()
|
||||
|
||||
function nowStack() {
|
||||
try {
|
||||
return new Error().stack || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function getRelayPort() {
|
||||
const stored = await chrome.storage.local.get(['relayPort'])
|
||||
const raw = stored.relayPort
|
||||
const n = Number.parseInt(String(raw || ''), 10)
|
||||
if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT
|
||||
return n
|
||||
}
|
||||
|
||||
function setBadge(tabId, kind) {
|
||||
const cfg = BADGE[kind]
|
||||
void chrome.action.setBadgeText({ tabId, text: cfg.text })
|
||||
void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color })
|
||||
void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {})
|
||||
}
|
||||
|
||||
async function ensureRelayConnection() {
|
||||
if (relayWs && relayWs.readyState === WebSocket.OPEN) return
|
||||
if (relayConnectPromise) return await relayConnectPromise
|
||||
|
||||
relayConnectPromise = (async () => {
|
||||
const port = await getRelayPort()
|
||||
const httpBase = `http://127.0.0.1:${port}`
|
||||
const wsUrl = `ws://127.0.0.1:${port}/extension`
|
||||
|
||||
// Fast preflight: is the relay server up?
|
||||
try {
|
||||
await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
|
||||
} catch (err) {
|
||||
throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
|
||||
}
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
relayWs = ws
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000)
|
||||
ws.onopen = () => {
|
||||
clearTimeout(t)
|
||||
resolve()
|
||||
}
|
||||
ws.onerror = () => {
|
||||
clearTimeout(t)
|
||||
reject(new Error('WebSocket connect failed'))
|
||||
}
|
||||
ws.onclose = (ev) => {
|
||||
clearTimeout(t)
|
||||
reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`))
|
||||
}
|
||||
})
|
||||
|
||||
ws.onmessage = (event) => void onRelayMessage(String(event.data || ''))
|
||||
ws.onclose = () => onRelayClosed('closed')
|
||||
ws.onerror = () => onRelayClosed('error')
|
||||
|
||||
if (!debuggerListenersInstalled) {
|
||||
debuggerListenersInstalled = true
|
||||
chrome.debugger.onEvent.addListener(onDebuggerEvent)
|
||||
chrome.debugger.onDetach.addListener(onDebuggerDetach)
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
await relayConnectPromise
|
||||
} finally {
|
||||
relayConnectPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
function onRelayClosed(reason) {
|
||||
relayWs = null
|
||||
for (const [id, p] of pending.entries()) {
|
||||
pending.delete(id)
|
||||
p.reject(new Error(`Relay disconnected (${reason})`))
|
||||
}
|
||||
|
||||
for (const tabId of tabs.keys()) {
|
||||
void chrome.debugger.detach({ tabId }).catch(() => {})
|
||||
setBadge(tabId, 'connecting')
|
||||
void chrome.action.setTitle({
|
||||
tabId,
|
||||
title: 'Clawdbot Browser Relay: disconnected (click to re-attach)',
|
||||
})
|
||||
}
|
||||
tabs.clear()
|
||||
tabBySession.clear()
|
||||
childSessionToTab.clear()
|
||||
}
|
||||
|
||||
function sendToRelay(payload) {
|
||||
const ws = relayWs
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('Relay not connected')
|
||||
}
|
||||
ws.send(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
async function maybeOpenHelpOnce() {
|
||||
try {
|
||||
const stored = await chrome.storage.local.get(['helpOnErrorShown'])
|
||||
if (stored.helpOnErrorShown === true) return
|
||||
await chrome.storage.local.set({ helpOnErrorShown: true })
|
||||
await chrome.runtime.openOptionsPage()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function requestFromRelay(command) {
|
||||
const id = command.id
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject })
|
||||
try {
|
||||
sendToRelay(command)
|
||||
} catch (err) {
|
||||
pending.delete(id)
|
||||
reject(err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function onRelayMessage(text) {
|
||||
/** @type {any} */
|
||||
let msg
|
||||
try {
|
||||
msg = JSON.parse(text)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (msg && msg.method === 'ping') {
|
||||
try {
|
||||
sendToRelay({ method: 'pong' })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) {
|
||||
const p = pending.get(msg.id)
|
||||
if (!p) return
|
||||
pending.delete(msg.id)
|
||||
if (msg.error) p.reject(new Error(String(msg.error)))
|
||||
else p.resolve(msg.result)
|
||||
return
|
||||
}
|
||||
|
||||
if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') {
|
||||
try {
|
||||
const result = await handleForwardCdpCommand(msg)
|
||||
sendToRelay({ id: msg.id, result })
|
||||
} catch (err) {
|
||||
sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getTabBySessionId(sessionId) {
|
||||
const direct = tabBySession.get(sessionId)
|
||||
if (direct) return { tabId: direct, kind: 'main' }
|
||||
const child = childSessionToTab.get(sessionId)
|
||||
if (child) return { tabId: child, kind: 'child' }
|
||||
return null
|
||||
}
|
||||
|
||||
function getTabByTargetId(targetId) {
|
||||
for (const [tabId, tab] of tabs.entries()) {
|
||||
if (tab.targetId === targetId) return tabId
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function attachTab(tabId, opts = {}) {
|
||||
const debuggee = { tabId }
|
||||
await chrome.debugger.attach(debuggee, '1.3')
|
||||
await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {})
|
||||
|
||||
const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'))
|
||||
const targetInfo = info?.targetInfo
|
||||
const targetId = String(targetInfo?.targetId || '').trim()
|
||||
if (!targetId) {
|
||||
throw new Error('Target.getTargetInfo returned no targetId')
|
||||
}
|
||||
|
||||
const sessionId = `cb-tab-${nextSession++}`
|
||||
const attachOrder = nextSession
|
||||
|
||||
tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder })
|
||||
tabBySession.set(sessionId, tabId)
|
||||
void chrome.action.setTitle({
|
||||
tabId,
|
||||
title: 'Clawdbot Browser Relay: attached (click to detach)',
|
||||
})
|
||||
|
||||
if (!opts.skipAttachedEvent) {
|
||||
sendToRelay({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
method: 'Target.attachedToTarget',
|
||||
params: {
|
||||
sessionId,
|
||||
targetInfo: { ...targetInfo, attached: true },
|
||||
waitingForDebugger: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
setBadge(tabId, 'on')
|
||||
return { sessionId, targetId }
|
||||
}
|
||||
|
||||
async function detachTab(tabId, reason) {
|
||||
const tab = tabs.get(tabId)
|
||||
if (tab?.sessionId && tab?.targetId) {
|
||||
try {
|
||||
sendToRelay({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
method: 'Target.detachedFromTarget',
|
||||
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason },
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
|
||||
tabs.delete(tabId)
|
||||
|
||||
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
|
||||
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.debugger.detach({ tabId })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
setBadge(tabId, 'off')
|
||||
void chrome.action.setTitle({
|
||||
tabId,
|
||||
title: 'Clawdbot Browser Relay (click to attach/detach)',
|
||||
})
|
||||
}
|
||||
|
||||
async function connectOrToggleForActiveTab() {
|
||||
const [active] = await chrome.tabs.query({ active: true, currentWindow: true })
|
||||
const tabId = active?.id
|
||||
if (!tabId) return
|
||||
|
||||
const existing = tabs.get(tabId)
|
||||
if (existing?.state === 'connected') {
|
||||
await detachTab(tabId, 'toggle')
|
||||
return
|
||||
}
|
||||
|
||||
tabs.set(tabId, { state: 'connecting' })
|
||||
setBadge(tabId, 'connecting')
|
||||
void chrome.action.setTitle({
|
||||
tabId,
|
||||
title: 'Clawdbot Browser Relay: connecting to local relay…',
|
||||
})
|
||||
|
||||
try {
|
||||
await ensureRelayConnection()
|
||||
await attachTab(tabId)
|
||||
} catch (err) {
|
||||
tabs.delete(tabId)
|
||||
setBadge(tabId, 'error')
|
||||
void chrome.action.setTitle({
|
||||
tabId,
|
||||
title: 'Clawdbot Browser Relay: relay not running (open options for setup)',
|
||||
})
|
||||
void maybeOpenHelpOnce()
|
||||
// Extra breadcrumbs in chrome://extensions service worker logs.
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.warn('attach failed', message, nowStack())
|
||||
}
|
||||
}
|
||||
|
||||
async function handleForwardCdpCommand(msg) {
|
||||
const method = String(msg?.params?.method || '').trim()
|
||||
const params = msg?.params?.params || undefined
|
||||
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
|
||||
|
||||
// Map command to tab
|
||||
const bySession = sessionId ? getTabBySessionId(sessionId) : null
|
||||
const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined
|
||||
const tabId =
|
||||
bySession?.tabId ||
|
||||
(targetId ? getTabByTargetId(targetId) : null) ||
|
||||
(() => {
|
||||
// No sessionId: pick the first connected tab (stable-ish).
|
||||
for (const [id, tab] of tabs.entries()) {
|
||||
if (tab.state === 'connected') return id
|
||||
}
|
||||
return null
|
||||
})()
|
||||
|
||||
if (!tabId) throw new Error(`No attached tab for method ${method}`)
|
||||
|
||||
/** @type {chrome.debugger.DebuggerSession} */
|
||||
const debuggee = { tabId }
|
||||
|
||||
if (method === 'Runtime.enable') {
|
||||
try {
|
||||
await chrome.debugger.sendCommand(debuggee, 'Runtime.disable')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params)
|
||||
}
|
||||
|
||||
if (method === 'Target.createTarget') {
|
||||
const url = typeof params?.url === 'string' ? params.url : 'about:blank'
|
||||
const tab = await chrome.tabs.create({ url, active: false })
|
||||
if (!tab.id) throw new Error('Failed to create tab')
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
const attached = await attachTab(tab.id)
|
||||
return { targetId: attached.targetId }
|
||||
}
|
||||
|
||||
if (method === 'Target.closeTarget') {
|
||||
const target = typeof params?.targetId === 'string' ? params.targetId : ''
|
||||
const toClose = target ? getTabByTargetId(target) : tabId
|
||||
if (!toClose) return { success: false }
|
||||
try {
|
||||
await chrome.tabs.remove(toClose)
|
||||
} catch {
|
||||
return { success: false }
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
if (method === 'Target.activateTarget') {
|
||||
const target = typeof params?.targetId === 'string' ? params.targetId : ''
|
||||
const toActivate = target ? getTabByTargetId(target) : tabId
|
||||
if (!toActivate) return {}
|
||||
const tab = await chrome.tabs.get(toActivate).catch(() => null)
|
||||
if (!tab) return {}
|
||||
if (tab.windowId) {
|
||||
await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
|
||||
}
|
||||
await chrome.tabs.update(toActivate, { active: true }).catch(() => {})
|
||||
return {}
|
||||
}
|
||||
|
||||
const tabState = tabs.get(tabId)
|
||||
const mainSessionId = tabState?.sessionId
|
||||
const debuggerSession =
|
||||
sessionId && mainSessionId && sessionId !== mainSessionId
|
||||
? { ...debuggee, sessionId }
|
||||
: debuggee
|
||||
|
||||
return await chrome.debugger.sendCommand(debuggerSession, method, params)
|
||||
}
|
||||
|
||||
function onDebuggerEvent(source, method, params) {
|
||||
const tabId = source.tabId
|
||||
if (!tabId) return
|
||||
const tab = tabs.get(tabId)
|
||||
if (!tab?.sessionId) return
|
||||
|
||||
if (method === 'Target.attachedToTarget' && params?.sessionId) {
|
||||
childSessionToTab.set(String(params.sessionId), tabId)
|
||||
}
|
||||
|
||||
if (method === 'Target.detachedFromTarget' && params?.sessionId) {
|
||||
childSessionToTab.delete(String(params.sessionId))
|
||||
}
|
||||
|
||||
try {
|
||||
sendToRelay({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
sessionId: source.sessionId || tab.sessionId,
|
||||
method,
|
||||
params,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function onDebuggerDetach(source, reason) {
|
||||
const tabId = source.tabId
|
||||
if (!tabId) return
|
||||
if (!tabs.has(tabId)) return
|
||||
void detachTab(tabId, reason)
|
||||
}
|
||||
|
||||
chrome.action.onClicked.addListener(() => void connectOrToggleForActiveTab())
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
// Useful: first-time instructions.
|
||||
void chrome.runtime.openOptionsPage()
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 614 B |
Binary file not shown.
|
After Width: | Height: | Size: 265 B |
Binary file not shown.
|
After Width: | Height: | Size: 318 B |
Binary file not shown.
|
After Width: | Height: | Size: 370 B |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Clawdbot Browser Relay",
|
||||
"version": "0.1.0",
|
||||
"description": "Attach Clawdbot to your existing Chrome tab via a local CDP relay server.",
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"permissions": ["debugger", "tabs", "activeTab", "storage"],
|
||||
"host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
|
||||
"background": { "service_worker": "background.js", "type": "module" },
|
||||
"action": {
|
||||
"default_title": "Clawdbot Browser Relay (click to attach/detach)",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"options_ui": { "page": "options.html", "open_in_tab": true }
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Clawdbot Browser Relay</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--accent: #ff5a36;
|
||||
--panel: color-mix(in oklab, canvas 92%, canvasText 8%);
|
||||
--border: color-mix(in oklab, canvasText 18%, transparent);
|
||||
--muted: color-mix(in oklab, canvasText 70%, transparent);
|
||||
--shadow: 0 10px 30px color-mix(in oklab, canvasText 18%, transparent);
|
||||
font-family: ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Rounded",
|
||||
"SF Pro Display", "Segoe UI", sans-serif;
|
||||
line-height: 1.4;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(1000px 500px at 10% 0%, color-mix(in oklab, var(--accent) 30%, transparent), transparent 70%),
|
||||
radial-gradient(900px 450px at 90% 0%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 75%),
|
||||
canvas;
|
||||
color: canvasText;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 820px;
|
||||
margin: 36px auto;
|
||||
padding: 0 24px 48px 24px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in oklab, var(--accent) 18%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent);
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.logo img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 2px 0 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.card p {
|
||||
margin: 8px 0 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
input {
|
||||
width: 160px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in oklab, canvas 92%, canvasText 8%);
|
||||
color: canvasText;
|
||||
outline: none;
|
||||
}
|
||||
input:focus {
|
||||
border-color: color-mix(in oklab, var(--accent) 70%, transparent);
|
||||
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 20%, transparent);
|
||||
}
|
||||
button {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in oklab, var(--accent) 55%, transparent);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--accent) 80%, white 20%),
|
||||
var(--accent)
|
||||
);
|
||||
color: white;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
code {
|
||||
font-family: ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
a {
|
||||
color: color-mix(in oklab, var(--accent) 85%, canvasText 15%);
|
||||
}
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in oklab, var(--accent) 70%, canvasText 30%);
|
||||
min-height: 16px;
|
||||
}
|
||||
.status[data-kind='ok'] {
|
||||
color: color-mix(in oklab, #16a34a 75%, canvasText 25%);
|
||||
}
|
||||
.status[data-kind='error'] {
|
||||
color: color-mix(in oklab, #ef4444 75%, canvasText 25%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="logo" aria-hidden="true">
|
||||
<img src="icons/icon128.png" alt="" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Clawdbot Browser Relay</h1>
|
||||
<p class="subtitle">Click the toolbar button on a tab to attach / detach.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Getting started</h2>
|
||||
<p>
|
||||
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
|
||||
Start Clawdbot’s browser relay on this machine (Gateway or <code>clawdbot browser serve</code>),
|
||||
then click the toolbar button again.
|
||||
</p>
|
||||
<p>
|
||||
Full guide (install, remote Gateway, security): <a href="https://docs.clawd.bot/tools/chrome-extension" target="_blank" rel="noreferrer">docs.clawd.bot/tools/chrome-extension</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Relay port</h2>
|
||||
<label for="port">Port</label>
|
||||
<div class="row">
|
||||
<input id="port" inputmode="numeric" pattern="[0-9]*" />
|
||||
<button id="save" type="button">Save</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
Default: <code>18792</code>. Extension connects to: <code id="relay-url">http://127.0.0.1:<port>/</code>.
|
||||
Only change this if your Clawdbot profile uses a different <code>cdpUrl</code> port.
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="options.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
const DEFAULT_PORT = 18792
|
||||
|
||||
function clampPort(value) {
|
||||
const n = Number.parseInt(String(value || ''), 10)
|
||||
if (!Number.isFinite(n)) return DEFAULT_PORT
|
||||
if (n <= 0 || n > 65535) return DEFAULT_PORT
|
||||
return n
|
||||
}
|
||||
|
||||
function updateRelayUrl(port) {
|
||||
const el = document.getElementById('relay-url')
|
||||
if (!el) return
|
||||
el.textContent = `http://127.0.0.1:${port}/`
|
||||
}
|
||||
|
||||
function setStatus(kind, message) {
|
||||
const status = document.getElementById('status')
|
||||
if (!status) return
|
||||
status.dataset.kind = kind || ''
|
||||
status.textContent = message || ''
|
||||
}
|
||||
|
||||
async function checkRelayReachable(port) {
|
||||
const url = `http://127.0.0.1:${port}/`
|
||||
const ctrl = new AbortController()
|
||||
const t = setTimeout(() => ctrl.abort(), 900)
|
||||
try {
|
||||
const res = await fetch(url, { method: 'HEAD', signal: ctrl.signal })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
setStatus('ok', `Relay reachable at ${url}`)
|
||||
} catch {
|
||||
setStatus(
|
||||
'error',
|
||||
`Relay not reachable at ${url}. Start Clawdbot’s browser relay on this machine, then click the toolbar button again.`,
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(t)
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const stored = await chrome.storage.local.get(['relayPort'])
|
||||
const port = clampPort(stored.relayPort)
|
||||
document.getElementById('port').value = String(port)
|
||||
updateRelayUrl(port)
|
||||
await checkRelayReachable(port)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const input = document.getElementById('port')
|
||||
const port = clampPort(input.value)
|
||||
await chrome.storage.local.set({ relayPort: port })
|
||||
input.value = String(port)
|
||||
updateRelayUrl(port)
|
||||
await checkRelayReachable(port)
|
||||
}
|
||||
|
||||
document.getElementById('save').addEventListener('click', () => void save())
|
||||
void load()
|
||||
@@ -18,6 +18,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||
- [iMessage](/channels/imessage) — macOS only; native integration.
|
||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support.
|
||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin).
|
||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
summary: "Zalo bot support status, capabilities, and configuration"
|
||||
read_when:
|
||||
- Working on Zalo features or webhooks
|
||||
---
|
||||
# Zalo (Bot API)
|
||||
|
||||
Status: experimental. Direct messages only; groups coming soon per Zalo docs.
|
||||
|
||||
## Quick setup (beginner)
|
||||
1) Install the Zalo plugin:
|
||||
- From a source checkout: `clawdbot plugins install ./extensions/zalo`
|
||||
- From npm (if published): `clawdbot plugins install @clawdbot/zalo`
|
||||
- Or pick **Zalo** in onboarding and confirm the install prompt
|
||||
2) Set the token:
|
||||
- Env: `ZALO_BOT_TOKEN=...`
|
||||
- Or config: `channels.zalo.botToken: "..."`.
|
||||
3) Restart the gateway (or finish onboarding).
|
||||
4) DM access is pairing by default; approve the pairing code on first contact.
|
||||
|
||||
Minimal config:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalo: {
|
||||
enabled: true,
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "pairing"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What it is
|
||||
- A Zalo Bot API channel owned by the Gateway.
|
||||
- Deterministic routing: replies go back to Zalo; the model never chooses channels.
|
||||
- DMs share the agent's main session.
|
||||
- Groups are not yet supported (Zalo docs state "coming soon").
|
||||
|
||||
## Setup (fast path)
|
||||
|
||||
### 1) Create a bot token (Zalo Bot Platform)
|
||||
1) Go to **https://bot.zaloplatforms.com** and sign in.
|
||||
2) Create a new bot and configure its settings.
|
||||
3) Copy the bot token (format: `12345689:abc-xyz`).
|
||||
|
||||
### 2) Configure the token (env or config)
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalo: {
|
||||
enabled: true,
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "pairing"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Env option: `ZALO_BOT_TOKEN=...` (works for the default account only).
|
||||
|
||||
Multi-account support: use `channels.zalo.accounts` with per-account tokens and optional `name`.
|
||||
|
||||
3) Restart the gateway. Zalo starts when a token is resolved (env or config).
|
||||
4) DM access defaults to pairing. Approve the code when the bot is first contacted.
|
||||
|
||||
## How it works (behavior)
|
||||
- Inbound messages are normalized into the shared channel envelope with media placeholders.
|
||||
- Replies always route back to the same Zalo chat.
|
||||
- Long-polling by default; webhook mode available with `channels.zalo.webhookUrl`.
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to 2000 characters (Zalo API limit).
|
||||
- Media downloads/uploads are capped by `channels.zalo.mediaMaxMb` (default 5).
|
||||
- Streaming is blocked by default due to the 2000 char limit making streaming less useful.
|
||||
|
||||
## Access control (DMs)
|
||||
|
||||
### DM access
|
||||
- Default: `channels.zalo.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
|
||||
- Approve via:
|
||||
- `clawdbot pairing list zalo`
|
||||
- `clawdbot pairing approve zalo <CODE>`
|
||||
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
|
||||
- `channels.zalo.allowFrom` accepts numeric user IDs.
|
||||
|
||||
## Long-polling vs webhook
|
||||
- Default: long-polling (no public URL required).
|
||||
- Webhook mode: set `channels.zalo.webhookUrl` and `channels.zalo.webhookSecret`.
|
||||
- The webhook secret must be 8-256 characters.
|
||||
- Webhook URL must use HTTPS.
|
||||
- Zalo sends events with `X-Bot-Api-Secret-Token` header for verification.
|
||||
- Gateway HTTP handles webhook requests at `channels.zalo.webhookPath` (defaults to the webhook URL path).
|
||||
|
||||
**Note:** getUpdates (polling) and webhook are mutually exclusive per Zalo API docs.
|
||||
|
||||
## Supported message types
|
||||
- **Text messages**: Full support with 2000 character chunking.
|
||||
- **Image messages**: Download and process inbound images; send images via `sendPhoto`.
|
||||
- **Stickers**: Logged but not fully processed (no agent response).
|
||||
- **Unsupported types**: Logged (e.g., messages from protected users).
|
||||
|
||||
## Capabilities
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Direct messages | ✅ Supported |
|
||||
| Groups | ❌ Coming soon (per Zalo docs) |
|
||||
| Media (images) | ✅ Supported |
|
||||
| Reactions | ❌ Not supported |
|
||||
| Threads | ❌ Not supported |
|
||||
| Polls | ❌ Not supported |
|
||||
| Native commands | ❌ Not supported |
|
||||
| Streaming | ⚠️ Blocked (2000 char limit) |
|
||||
|
||||
## Delivery targets (CLI/cron)
|
||||
- Use a chat id as the target.
|
||||
- Example: `clawdbot message send --channel zalo --to 123456789 --message "hi"`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Bot doesn't respond:**
|
||||
- Check that the token is valid: `clawdbot channels status --probe`
|
||||
- Verify the sender is approved (pairing or allowFrom)
|
||||
- Check gateway logs: `clawdbot logs --follow`
|
||||
|
||||
**Webhook not receiving events:**
|
||||
- Ensure webhook URL uses HTTPS
|
||||
- Verify secret token is 8-256 characters
|
||||
- Confirm the gateway HTTP endpoint is reachable on the configured path
|
||||
- Check that getUpdates polling is not running (they're mutually exclusive)
|
||||
|
||||
## Configuration reference (Zalo)
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
Provider options:
|
||||
- `channels.zalo.enabled`: enable/disable channel startup.
|
||||
- `channels.zalo.botToken`: bot token from Zalo Bot Platform.
|
||||
- `channels.zalo.tokenFile`: read token from file path.
|
||||
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`.
|
||||
- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5).
|
||||
- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required).
|
||||
- `channels.zalo.webhookSecret`: webhook secret (8-256 chars).
|
||||
- `channels.zalo.webhookPath`: webhook path on the gateway HTTP server.
|
||||
- `channels.zalo.proxy`: proxy URL for API requests.
|
||||
|
||||
Multi-account options:
|
||||
- `channels.zalo.accounts.<id>.botToken`: per-account token.
|
||||
- `channels.zalo.accounts.<id>.tokenFile`: per-account token file.
|
||||
- `channels.zalo.accounts.<id>.name`: display name.
|
||||
- `channels.zalo.accounts.<id>.enabled`: enable/disable account.
|
||||
- `channels.zalo.accounts.<id>.dmPolicy`: per-account DM policy.
|
||||
- `channels.zalo.accounts.<id>.allowFrom`: per-account allowlist.
|
||||
- `channels.zalo.accounts.<id>.webhookUrl`: per-account webhook URL.
|
||||
- `channels.zalo.accounts.<id>.webhookSecret`: per-account webhook secret.
|
||||
- `channels.zalo.accounts.<id>.webhookPath`: per-account webhook path.
|
||||
- `channels.zalo.accounts.<id>.proxy`: per-account proxy URL.
|
||||
@@ -48,6 +48,8 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
onboard
|
||||
configure (alias: config)
|
||||
doctor
|
||||
security
|
||||
audit
|
||||
reset
|
||||
uninstall
|
||||
update
|
||||
@@ -180,6 +182,12 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
|
||||
Note: plugins can add additional top-level commands (for example `clawdbot voicecall`).
|
||||
|
||||
## Security
|
||||
|
||||
- `clawdbot security audit` — audit config + local state for common security foot-guns.
|
||||
- `clawdbot security audit --deep` — best-effort live Gateway probe.
|
||||
- `clawdbot security audit --fix` — tighten safe defaults and chmod state/config.
|
||||
|
||||
## Plugins
|
||||
|
||||
Manage extensions and their config:
|
||||
@@ -579,8 +587,12 @@ Subcommands:
|
||||
|
||||
Common RPCs:
|
||||
- `config.apply` (validate + write config + restart + wake)
|
||||
- `config.patch` (merge a partial update without clobbering unrelated keys)
|
||||
- `update.run` (run update + restart + wake)
|
||||
|
||||
Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `baseHash` from
|
||||
`config.get` if a config already exists.
|
||||
|
||||
## Models
|
||||
|
||||
See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy.
|
||||
|
||||
@@ -46,6 +46,70 @@ If you want...
|
||||
- Direct chats use the main session (or per-sender if configured).
|
||||
- Heartbeats are skipped for group sessions.
|
||||
|
||||
## Pattern: personal DMs + public groups (single agent)
|
||||
|
||||
Yes — this works well if your “personal” traffic is **DMs** and your “public” traffic is **groups**.
|
||||
|
||||
Why: in single-agent mode, DMs typically land in the **main** session key (`agent:main:main`), while groups always use **non-main** session keys (`agent:main:<channel>:group:<id>`). If you enable sandboxing with `mode: "non-main"`, those group sessions run in Docker while your main DM session stays on-host.
|
||||
|
||||
This gives you one agent “brain” (shared workspace + memory), but two execution postures:
|
||||
- **DMs**: full tools (host)
|
||||
- **Groups**: sandbox + restricted tools (Docker)
|
||||
|
||||
> If you need truly separate workspaces/personas (“personal” and “public” must never mix), use a second agent + bindings. See [Multi-Agent Routing](/concepts/multi-agent).
|
||||
|
||||
Example (DMs on host, groups sandboxed + messaging-only tools):
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "non-main", // groups/channels are non-main -> sandboxed
|
||||
scope: "session", // strongest isolation (one container per group/channel)
|
||||
workspaceAccess: "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
// If allow is non-empty, everything else is blocked (deny still wins).
|
||||
allow: ["group:messaging", "group:sessions"],
|
||||
deny: ["group:runtime", "group:fs", "group:ui", "nodes", "cron", "gateway"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Want “groups can only see folder X” instead of “no host access”? Keep `workspaceAccess: "none"` and mount only allowlisted paths into the sandbox:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "non-main",
|
||||
scope: "session",
|
||||
workspaceAccess: "none",
|
||||
docker: {
|
||||
binds: [
|
||||
// hostPath:containerPath:mode
|
||||
"~/FriendsShared:/data:ro"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Related:
|
||||
- Configuration keys and defaults: [Gateway configuration](/gateway/configuration#agentsdefaultssandbox)
|
||||
- Debugging why a tool is blocked: [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)
|
||||
- Bind mounts details: [Sandboxing](/gateway/sandboxing#custom-bind-mounts)
|
||||
|
||||
## Display labels
|
||||
- UI labels use `displayName` when available, formatted as `<channel>:<token>`.
|
||||
- `#room` is reserved for rooms/channels; group chats use `g-<slug>` (lowercase, spaces -> `-`, keep `#@+._-`).
|
||||
|
||||
@@ -17,6 +17,16 @@ An **agent** is a fully scoped brain with its own:
|
||||
- **State directory** (`agentDir`) for auth profiles, model registry, and per-agent config.
|
||||
- **Session store** (chat history + routing state) under `~/.clawdbot/agents/<agentId>/sessions`.
|
||||
|
||||
Auth profiles are **per-agent**. Each agent reads from its own:
|
||||
|
||||
```
|
||||
~/.clawdbot/agents/<agentId>/agent/auth-profiles.json
|
||||
```
|
||||
|
||||
Main agent credentials are **not** shared automatically. Never reuse `agentDir`
|
||||
across agents (it causes auth/session collisions). If you want to share creds,
|
||||
copy `auth-profiles.json` into the other agent's `agentDir`.
|
||||
|
||||
Skills are per-agent via each workspace’s `skills/` folder, with shared skills
|
||||
available from `~/.clawdbot/skills`. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills).
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@ Behavior:
|
||||
- Always non-blocking: returns `{ status: "accepted", runId, childSessionKey }` immediately.
|
||||
- After completion, Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat channel.
|
||||
- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent.
|
||||
- Announce replies are normalized to `Status`/`Result`/`Notes`; `Status` comes from runtime outcome (not model text).
|
||||
- Sub-agent sessions are auto-archived after `agents.defaults.subagents.archiveAfterMinutes` (default: 60).
|
||||
- Announce replies include a stats line (runtime, tokens, sessionKey/sessionId, transcript path, and optional cost).
|
||||
|
||||
|
||||
@@ -889,9 +889,11 @@
|
||||
"tools",
|
||||
"plugin",
|
||||
"tools/exec",
|
||||
"tools/web",
|
||||
"tools/apply-patch",
|
||||
"tools/elevated",
|
||||
"tools/browser",
|
||||
"tools/chrome-extension",
|
||||
"tools/browser-linux-troubleshooting",
|
||||
"tools/slash-commands",
|
||||
"tools/thinking",
|
||||
|
||||
@@ -384,7 +384,7 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
},
|
||||
|
||||
skills: {
|
||||
allowBundled: ["brave-search", "gemini"],
|
||||
allowBundled: ["gemini", "peekaboo"],
|
||||
load: {
|
||||
extraDirs: ["~/Projects/agent-scripts/skills"]
|
||||
},
|
||||
|
||||
@@ -32,19 +32,44 @@ It writes a restart sentinel and pings the last active session after the Gateway
|
||||
|
||||
Params:
|
||||
- `raw` (string) — JSON5 payload for the entire config
|
||||
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
|
||||
- `sessionKey` (optional) — last active session key for the wake-up ping
|
||||
- `restartDelayMs` (optional) — delay before restart (default 2000)
|
||||
|
||||
Example (via `gateway call`):
|
||||
|
||||
```bash
|
||||
clawdbot gateway call config.get --params '{}' # capture payload.hash
|
||||
clawdbot gateway call config.apply --params '{
|
||||
"raw": "{\\n agents: { defaults: { workspace: \\"~/clawd\\" } }\\n}\\n",
|
||||
"baseHash": "<hash-from-config.get>",
|
||||
"sessionKey": "agent:main:whatsapp:dm:+15555550123",
|
||||
"restartDelayMs": 1000
|
||||
}'
|
||||
```
|
||||
|
||||
## Partial updates (RPC)
|
||||
|
||||
Use `config.patch` to merge a partial update into the existing config without clobbering
|
||||
unrelated keys. It applies JSON merge patch semantics:
|
||||
- objects merge recursively
|
||||
- `null` deletes a key
|
||||
- arrays replace
|
||||
|
||||
Params:
|
||||
- `raw` (string) — JSON5 payload containing just the keys to change
|
||||
- `baseHash` (required) — config hash from `config.get`
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
clawdbot gateway call config.get --params '{}' # capture payload.hash
|
||||
clawdbot gateway call config.patch --params '{
|
||||
"raw": "{\\n channels: { telegram: { groups: { \\"*\\": { requireMention: false } } } }\\n}\\n",
|
||||
"baseHash": "<hash-from-config.get>"
|
||||
}'
|
||||
```
|
||||
|
||||
## Minimal config (recommended starting point)
|
||||
|
||||
```json5
|
||||
@@ -1580,6 +1605,18 @@ of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
|
||||
Note: `applyPatch` is only under `tools.exec` (no `tools.bash` alias).
|
||||
Legacy: `tools.bash` is still accepted as an alias.
|
||||
|
||||
`tools.web` configures web search + fetch tools:
|
||||
- `tools.web.search.enabled` (default: true when key is present)
|
||||
- `tools.web.search.apiKey` (recommended: set via `clawdbot configure --section web`, or use `BRAVE_API_KEY` env var)
|
||||
- `tools.web.search.maxResults` (1–10, default 5)
|
||||
- `tools.web.search.timeoutSeconds` (default 30)
|
||||
- `tools.web.search.cacheTtlMinutes` (default 15)
|
||||
- `tools.web.fetch.enabled` (default false; sandboxed sessions auto-enable unless set to false)
|
||||
- `tools.web.fetch.maxChars` (default 50000)
|
||||
- `tools.web.fetch.timeoutSeconds` (default 30)
|
||||
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
||||
- `tools.web.fetch.userAgent` (optional override)
|
||||
|
||||
`agents.defaults.subagents` configures sub-agent defaults:
|
||||
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
|
||||
- `maxConcurrent`: max concurrent sub-agent runs (default 1)
|
||||
@@ -1614,6 +1651,37 @@ Example (coding profile, but deny exec/process everywhere):
|
||||
}
|
||||
```
|
||||
|
||||
`tools.byProvider` lets you **further restrict** tools for specific providers (or a single `provider/model`).
|
||||
Per-agent override: `agents.list[].tools.byProvider`.
|
||||
|
||||
Order: base profile → provider profile → allow/deny policies.
|
||||
Provider keys accept either `provider` (e.g. `google-antigravity`) or `provider/model`
|
||||
(e.g. `openai/gpt-5.2`).
|
||||
|
||||
Example (keep global coding profile, but minimal tools for Google Antigravity):
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
profile: "coding",
|
||||
byProvider: {
|
||||
"google-antigravity": { profile: "minimal" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example (provider/model-specific allowlist):
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
allow: ["group:fs", "group:runtime", "sessions_list"],
|
||||
byProvider: {
|
||||
"openai/gpt-5.2": { allow: ["group:fs", "sessions_list"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins).
|
||||
This is applied even when the Docker sandbox is **off**.
|
||||
|
||||
@@ -1629,6 +1697,7 @@ Tool groups (shorthands) work in **global** and **per-agent** tool policies:
|
||||
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
|
||||
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
|
||||
- `group:memory`: `memory_search`, `memory_get`
|
||||
- `group:web`: `web_search`, `web_fetch`
|
||||
- `group:ui`: `browser`, `canvas`
|
||||
- `group:automation`: `cron`, `gateway`
|
||||
- `group:messaging`: `message`
|
||||
@@ -2154,7 +2223,7 @@ Example:
|
||||
```json5
|
||||
{
|
||||
skills: {
|
||||
allowBundled: ["brave-search", "gemini"],
|
||||
allowBundled: ["gemini", "peekaboo"],
|
||||
load: {
|
||||
extraDirs: [
|
||||
"~/Projects/agent-scripts/skills",
|
||||
|
||||
@@ -51,12 +51,15 @@ See [Sandboxing](/gateway/sandboxing) for the full matrix (scope, workspace moun
|
||||
|
||||
Two layers matter:
|
||||
- **Tool profile**: `tools.profile` and `agents.list[].tools.profile` (base allowlist)
|
||||
- **Provider tool profile**: `tools.byProvider[provider].profile` and `agents.list[].tools.byProvider[provider].profile`
|
||||
- **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.list[].tools.allow`/`agents.list[].tools.deny`
|
||||
- **Provider tool policy**: `tools.byProvider[provider].allow/deny` and `agents.list[].tools.byProvider[provider].allow/deny`
|
||||
- **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*`
|
||||
|
||||
Rules of thumb:
|
||||
- `deny` always wins.
|
||||
- If `allow` is non-empty, everything else is treated as blocked.
|
||||
Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.2`).
|
||||
|
||||
### Tool groups (shorthands)
|
||||
|
||||
@@ -90,6 +93,7 @@ Available groups:
|
||||
Elevated does **not** grant extra tools; it only affects `exec`.
|
||||
- If you’re sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs on the host.
|
||||
- If you’re already running direct, elevated is effectively a no-op (still gated).
|
||||
- Elevated is **not** skill-scoped and does **not** override tool allow/deny.
|
||||
|
||||
Gates:
|
||||
- Enablement: `tools.elevated.enabled` (and optionally `agents.list[].tools.elevated.enabled`)
|
||||
|
||||
@@ -12,6 +12,23 @@ Clawdbot is both a product and an experiment: you’re wiring frontier-model beh
|
||||
- where the bot is allowed to act
|
||||
- what the bot can touch
|
||||
|
||||
## Quick check: `clawdbot security audit`
|
||||
|
||||
Run this regularly (especially after changing config or exposing network surfaces):
|
||||
|
||||
```bash
|
||||
clawdbot security audit
|
||||
clawdbot security audit --deep
|
||||
clawdbot security audit --fix
|
||||
```
|
||||
|
||||
It flags common footguns (Gateway auth exposure, browser control exposure, elevated allowlists, filesystem permissions).
|
||||
|
||||
`--fix` applies safe guardrails:
|
||||
- Tighten `groupPolicy="open"` to `groupPolicy="allowlist"` (and per-account variants) for common channels.
|
||||
- Turn `logging.redactSensitive="off"` back to `"tools"`.
|
||||
- Tighten local perms (`~/.clawdbot` → `700`, config file → `600`).
|
||||
|
||||
## The Threat Model
|
||||
|
||||
Your AI assistant can:
|
||||
@@ -190,6 +207,27 @@ you terminate TLS or proxy in front of the gateway, disable
|
||||
|
||||
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
|
||||
|
||||
### 0.6.1) Browser control server over Tailscale (recommended)
|
||||
|
||||
If your Gateway is remote but the browser runs on another machine, you’ll often run a **separate browser control server**
|
||||
on the browser machine (see [Browser tool](/tools/browser)). Treat this like an admin API.
|
||||
|
||||
Recommended pattern:
|
||||
|
||||
```bash
|
||||
# on the machine that runs Chrome
|
||||
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
|
||||
tailscale serve https / http://127.0.0.1:18791
|
||||
```
|
||||
|
||||
Then on the Gateway, set:
|
||||
- `browser.controlUrl` to the `https://…` Serve URL (MagicDNS/ts.net)
|
||||
- and authenticate with the same token (`CLAWDBOT_BROWSER_CONTROL_TOKEN` env preferred)
|
||||
|
||||
Avoid:
|
||||
- `--bind 0.0.0.0` (LAN-visible surface)
|
||||
- Tailscale Funnel for browser control endpoints (public exposure)
|
||||
|
||||
### 0.7) Secrets on disk (what’s sensitive)
|
||||
|
||||
Assume anything under `~/.clawdbot/` (or `$CLAWDBOT_STATE_DIR/`) may contain secrets or private data:
|
||||
@@ -320,6 +358,9 @@ access those accounts and data. Treat browser profiles as **sensitive state**:
|
||||
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
|
||||
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
|
||||
- For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach.
|
||||
- Treat `browser.controlUrl` endpoints as an admin API: tailnet-only + token auth. Prefer Tailscale Serve over LAN binds.
|
||||
- Keep `browser.controlToken` separate from `gateway.auth.token` (you can reuse it, but that increases blast radius).
|
||||
- Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach.
|
||||
|
||||
## Per-agent access profiles (multi-agent)
|
||||
|
||||
|
||||
@@ -76,6 +76,36 @@ clawdbot gateway --tailscale funnel --auth password
|
||||
- Serve/Funnel only expose the **Gateway control UI + WS**. Node **bridge** traffic
|
||||
uses the separate bridge port (default `18790`) and is **not** proxied by Serve.
|
||||
|
||||
## Browser control server (remote Gateway + local browser)
|
||||
|
||||
If you run the Gateway on one machine but want to drive a browser on another machine, use a **separate browser control server**
|
||||
and publish it through Tailscale **Serve** (tailnet-only):
|
||||
|
||||
```bash
|
||||
# on the machine that runs Chrome
|
||||
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
|
||||
tailscale serve https / http://127.0.0.1:18791
|
||||
```
|
||||
|
||||
Then point the Gateway config at the HTTPS URL:
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: "https://<magicdns>/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And authenticate from the Gateway with the same token (prefer env):
|
||||
|
||||
```bash
|
||||
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
|
||||
```
|
||||
|
||||
Avoid Funnel for browser control endpoints unless you explicitly want public exposure.
|
||||
|
||||
## Tailscale prerequisites + limits
|
||||
|
||||
- Serve requires HTTPS enabled for your tailnet; the CLI prompts if it is missing.
|
||||
|
||||
@@ -18,6 +18,15 @@ This allows you to run multiple agents with different security profiles:
|
||||
- Family/work agents with restricted tools
|
||||
- Public-facing agents in sandboxes
|
||||
|
||||
Auth is per-agent: each agent reads from its own `agentDir` auth store at:
|
||||
|
||||
```
|
||||
~/.clawdbot/agents/<agentId>/agent/auth-profiles.json
|
||||
```
|
||||
|
||||
Credentials are **not** shared between agents. Never reuse `agentDir` across agents.
|
||||
If you want to share creds, copy `auth-profiles.json` into the other agent's `agentDir`.
|
||||
|
||||
For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing).
|
||||
For debugging “why is this blocked?”, see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) and `clawdbot sandbox explain`.
|
||||
|
||||
@@ -188,14 +197,18 @@ agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.*
|
||||
### Tool Restrictions
|
||||
The filtering order is:
|
||||
1. **Tool profile** (`tools.profile` or `agents.list[].tools.profile`)
|
||||
2. **Global tool policy** (`tools.allow` / `tools.deny`)
|
||||
3. **Agent-specific tool policy** (`agents.list[].tools`)
|
||||
4. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
|
||||
5. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
|
||||
2. **Provider tool profile** (`tools.byProvider[provider].profile` or `agents.list[].tools.byProvider[provider].profile`)
|
||||
3. **Global tool policy** (`tools.allow` / `tools.deny`)
|
||||
4. **Provider tool policy** (`tools.byProvider[provider].allow/deny`)
|
||||
5. **Agent-specific tool policy** (`agents.list[].tools.allow/deny`)
|
||||
6. **Agent provider policy** (`agents.list[].tools.byProvider[provider].allow/deny`)
|
||||
7. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
|
||||
8. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
|
||||
|
||||
Each level can further restrict tools, but cannot grant back denied tools from earlier levels.
|
||||
If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent.
|
||||
If `agents.list[].tools.profile` is set, it overrides `tools.profile` for that agent.
|
||||
Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.2`).
|
||||
|
||||
### Tool groups (shorthands)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ Label:
|
||||
|
||||
Plist location (per‑user):
|
||||
- `~/Library/LaunchAgents/com.clawdbot.gateway.plist`
|
||||
(or `~/Library/LaunchAgents/com.clawdbot.<profile>.plist`)
|
||||
|
||||
Manager:
|
||||
- The macOS app owns LaunchAgent install/update in Local mode.
|
||||
|
||||
@@ -37,6 +37,7 @@ Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
||||
register:
|
||||
|
||||
- Gateway RPC methods
|
||||
- Gateway HTTP handlers
|
||||
- Agent tools
|
||||
- CLI commands
|
||||
- Background services
|
||||
|
||||
@@ -34,6 +34,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks)
|
||||
- [How do I install skills on Linux?](#how-do-i-install-skills-on-linux)
|
||||
- [Do you have a Notion or HeyGen integration?](#do-you-have-a-notion-or-heygen-integration)
|
||||
- [How do I install the Chrome extension for browser takeover?](#how-do-i-install-the-chrome-extension-for-browser-takeover)
|
||||
- [Sandboxing and memory](#sandboxing-and-memory)
|
||||
- [Is there a dedicated sandboxing doc?](#is-there-a-dedicated-sandboxing-doc)
|
||||
- [How do I bind a host folder into the sandbox?](#how-do-i-bind-a-host-folder-into-the-sandbox)
|
||||
@@ -50,6 +51,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [I set `gateway.bind: "lan"` (or `"tailnet"`) and now nothing listens / the UI says unauthorized](#i-set-gatewaybind-lan-or-tailnet-and-now-nothing-listens-the-ui-says-unauthorized)
|
||||
- [Why do I need a token on localhost now?](#why-do-i-need-a-token-on-localhost-now)
|
||||
- [Do I have to restart after changing config?](#do-i-have-to-restart-after-changing-config)
|
||||
- [How do I enable web search (and web fetch)?](#how-do-i-enable-web-search-and-web-fetch)
|
||||
- [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices)
|
||||
- [Can the Clawdbot browser run headless?](#can-the-clawdbot-browser-run-headless)
|
||||
- [Remote gateways + nodes](#remote-gateways-nodes)
|
||||
@@ -398,12 +400,38 @@ clawdhub update --all
|
||||
|
||||
ClawdHub installs into `./skills` under your current directory (or falls back to your configured Clawdbot workspace); Clawdbot treats that as `<workspace>/skills` on the next session. For shared skills across agents, place them in `~/.clawdbot/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawdHub](/tools/clawdhub).
|
||||
|
||||
### How do I install the Chrome extension for browser takeover?
|
||||
|
||||
Use the built-in installer, then load the unpacked extension in Chrome:
|
||||
|
||||
```bash
|
||||
clawdbot browser extension install
|
||||
clawdbot browser extension path
|
||||
```
|
||||
|
||||
Then Chrome → `chrome://extensions` → enable “Developer mode” → “Load unpacked” → pick that folder.
|
||||
|
||||
Full guide (including remote Gateway via Tailscale + security notes): [Chrome extension](/tools/chrome-extension)
|
||||
|
||||
If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need `clawdbot browser serve`.
|
||||
You still need to click the extension button on the tab you want to control (it doesn’t auto-attach).
|
||||
|
||||
## Sandboxing and memory
|
||||
|
||||
### Is there a dedicated sandboxing doc?
|
||||
|
||||
Yes. See [Sandboxing](/gateway/sandboxing). For Docker-specific setup (full gateway in Docker or sandbox images), see [Docker](/install/docker).
|
||||
|
||||
### Can I keep DMs “personal” but make groups “public/sandboxed” with one agent?
|
||||
|
||||
Yes — if your private traffic is **DMs** and your public traffic is **groups**.
|
||||
|
||||
Use `agents.defaults.sandbox.mode: "non-main"` so group/channel sessions (non-main keys) run in Docker, while the main DM session stays on-host. Then restrict what tools are available in sandboxed sessions via `tools.sandbox.tools`.
|
||||
|
||||
Setup walkthrough + example config: [Groups: personal DMs + public groups](/concepts/groups#pattern-personal-dms-public-groups-single-agent)
|
||||
|
||||
Key config reference: [Gateway configuration](/gateway/configuration#agentsdefaultssandbox)
|
||||
|
||||
### How do I bind a host folder into the sandbox?
|
||||
|
||||
Set `agents.defaults.sandbox.docker.binds` to `["host:path:mode"]` (e.g., `"/home/user/src:/src:ro"`). Global + per-agent binds merge; per-agent binds are ignored when `scope: "shared"`. Use `:ro` for anything sensitive and remember binds bypass the sandbox filesystem walls. See [Sandboxing](/gateway/sandboxing#custom-bind-mounts) and [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated#bind-mounts-security-quick-check) for examples and safety notes.
|
||||
@@ -548,6 +576,37 @@ The Gateway watches the config and supports hot‑reload:
|
||||
- `gateway.reload.mode: "hybrid"` (default): hot‑apply safe changes, restart for critical ones
|
||||
- `hot`, `restart`, `off` are also supported
|
||||
|
||||
### How do I enable web search (and web fetch)?
|
||||
|
||||
`web_fetch` works without an API key. `web_search` requires a Brave Search API
|
||||
key. **Recommended:** run `clawdbot configure --section web` to store it in
|
||||
`tools.web.search.apiKey`. Environment alternative: set `BRAVE_API_KEY` for the
|
||||
Gateway process.
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
apiKey: "BRAVE_API_KEY_HERE",
|
||||
maxResults: 5
|
||||
},
|
||||
fetch: {
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- If you use allowlists, add `web_search`/`web_fetch` or `group:web`.
|
||||
- In sandboxed sessions, `web_fetch` auto-enables unless explicitly disabled.
|
||||
- Daemons read env vars from `~/.clawdbot/.env` (or the service environment).
|
||||
|
||||
Docs: [Web tools](/tools/web).
|
||||
|
||||
### How do I run a central Gateway with specialized workers across devices?
|
||||
|
||||
The common pattern is **one Gateway** (e.g. Raspberry Pi) plus **nodes** and **agents**:
|
||||
@@ -988,6 +1047,21 @@ Z.AI (GLM models):
|
||||
|
||||
If you reference a provider/model but the required provider key is missing, you’ll get a runtime auth error (e.g. `No API key found for provider "zai"`).
|
||||
|
||||
### “No API key found for provider …” after adding a new agent
|
||||
|
||||
This usually means the **new agent** has an empty auth store. Auth is per-agent and
|
||||
stored in:
|
||||
|
||||
```
|
||||
~/.clawdbot/agents/<agentId>/agent/auth-profiles.json
|
||||
```
|
||||
|
||||
Fix options:
|
||||
- Run `clawdbot agents add <id>` and configure auth during the wizard.
|
||||
- Or copy `auth-profiles.json` from the main agent’s `agentDir` into the new agent’s `agentDir`.
|
||||
|
||||
Do **not** reuse `agentDir` across agents; it causes auth/session collisions.
|
||||
|
||||
## Model failover and “All models failed”
|
||||
|
||||
### How does failover work?
|
||||
@@ -1042,6 +1116,13 @@ If your model config includes Google Gemini as a fallback (or you switched to a
|
||||
|
||||
Fix: either provide Google auth, or remove/avoid Google models in `agents.defaults.model.fallbacks` / aliases so fallback doesn’t route there.
|
||||
|
||||
### “LLM request rejected: messages.*.thinking.signature required (google‑antigravity)”
|
||||
|
||||
Cause: the session history contains **thinking blocks without signatures** (often from
|
||||
an aborted/partial stream). Google Antigravity requires signatures for thinking blocks.
|
||||
|
||||
Fix: start a **new session** or set `/thinking off` for that agent.
|
||||
|
||||
## Auth profiles: what they are and how to manage them
|
||||
|
||||
Related: [/concepts/oauth](/concepts/oauth) (OAuth flows, token storage, multi-account patterns, CLI sync)
|
||||
|
||||
@@ -40,6 +40,9 @@ run on host, set an explicit per-agent override:
|
||||
|
||||
- Node `>=22`
|
||||
- `pnpm` (optional; recommended if you build from source)
|
||||
- **Recommended:** Brave Search API key for web search. Easiest path:
|
||||
`clawdbot configure --section web` (stores `tools.web.search.apiKey`).
|
||||
See [Web tools](/tools/web).
|
||||
|
||||
macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough.
|
||||
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested and more problematic. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).
|
||||
|
||||
@@ -24,6 +24,10 @@ Follow‑up reconfiguration:
|
||||
clawdbot configure
|
||||
```
|
||||
|
||||
Recommended: set up a Brave Search API key so the agent can use `web_search`
|
||||
(`web_fetch` works without a key). Easiest path: `clawdbot configure --section web`
|
||||
which stores `tools.web.search.apiKey`. Docs: [Web tools](/tools/web).
|
||||
|
||||
## QuickStart vs Advanced
|
||||
|
||||
The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
@@ -285,6 +289,9 @@ Typical fields in `~/.clawdbot/clawdbot.json`:
|
||||
WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp/<accountId>/`.
|
||||
Sessions are stored under `~/.clawdbot/agents/<agentId>/sessions/`.
|
||||
|
||||
Some channels are delivered as plugins. When you pick one during onboarding, the wizard
|
||||
will prompt to install it (npm or a local path) before it can be configured.
|
||||
|
||||
## Related docs
|
||||
|
||||
- macOS app onboarding: [Onboarding](/start/onboarding)
|
||||
|
||||
+144
-1
@@ -106,6 +106,95 @@ Example:
|
||||
Use `profiles.<name>.cdpUrl` for **remote CDP** if you want the Gateway to talk
|
||||
directly to a Chrome instance without a remote control server.
|
||||
|
||||
### Running the control server on the browser machine
|
||||
|
||||
Run a standalone browser control server (recommended when your Gateway is remote):
|
||||
|
||||
```bash
|
||||
# on the machine that runs Chrome
|
||||
clawdbot browser serve --bind <browser-host> --port 18791 --token <token>
|
||||
```
|
||||
|
||||
Then point your Gateway at it:
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: "http://<browser-host>:18791",
|
||||
|
||||
// Option A (recommended): keep token in env on the Gateway
|
||||
// (avoid writing secrets into config files)
|
||||
// controlToken: "<token>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And set the auth token in the Gateway environment:
|
||||
|
||||
```bash
|
||||
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
|
||||
```
|
||||
|
||||
Option B: store the token in the Gateway config instead (same shared token):
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: "http://<browser-host>:18791",
|
||||
controlToken: "<token>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
This section covers the **browser control server** (`browser.controlUrl`) used for agent browser automation.
|
||||
|
||||
Key ideas:
|
||||
- Treat the browser control server like an admin API: **private network only**.
|
||||
- Use **token auth** always when the server is reachable off-machine.
|
||||
- Prefer **Tailnet-only** connectivity over LAN exposure.
|
||||
|
||||
### Tokens (what is shared with what?)
|
||||
|
||||
- `browser.controlToken` / `CLAWDBOT_BROWSER_CONTROL_TOKEN` is **only** for authenticating browser control HTTP requests to `browser.controlUrl`.
|
||||
- It is **not** the Gateway token (`gateway.auth.token`) and **not** a node pairing token.
|
||||
- You *can* reuse the same string value, but it’s better to keep them separate to reduce blast radius.
|
||||
|
||||
### Binding (don’t expose to your LAN by accident)
|
||||
|
||||
Recommended:
|
||||
- Keep `clawdbot browser serve` bound to loopback (`127.0.0.1`) and publish it via Tailscale.
|
||||
- Or bind to a Tailnet IP only (never `0.0.0.0`) and require a token.
|
||||
|
||||
Avoid:
|
||||
- `--bind 0.0.0.0` (LAN-visible). Even with token auth, traffic is plain HTTP unless you also add TLS.
|
||||
|
||||
### TLS / HTTPS (recommended approach: terminate in front)
|
||||
|
||||
Best practice here: keep `clawdbot browser serve` on HTTP and terminate TLS in front.
|
||||
|
||||
If you’re already using Tailscale, you have two good options:
|
||||
|
||||
1) **Tailnet-only, still HTTP** (transport is encrypted by Tailscale):
|
||||
- Keep `controlUrl` as `http://…` but ensure it’s only reachable over your tailnet.
|
||||
|
||||
2) **Serve HTTPS via Tailscale** (nice UX: `https://…` URL):
|
||||
|
||||
```bash
|
||||
# on the browser machine
|
||||
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
|
||||
tailscale serve https / http://127.0.0.1:18791
|
||||
```
|
||||
|
||||
Then set your Gateway config `browser.controlUrl` to the HTTPS URL (MagicDNS/ts.net) and keep using the same token.
|
||||
|
||||
Notes:
|
||||
- Do **not** use Tailscale Funnel for this unless you explicitly want to make the endpoint public.
|
||||
- For Tailnet setup/background, see [Gateway web surfaces](/web/index) and the [Gateway CLI](/cli/gateway).
|
||||
|
||||
## Profiles (multi-browser)
|
||||
|
||||
Clawdbot supports multiple named profiles. Each profile has its own:
|
||||
@@ -120,6 +209,54 @@ Defaults:
|
||||
|
||||
All control endpoints accept `?profile=<name>`; the CLI uses `--browser-profile`.
|
||||
|
||||
## Chrome extension relay (use your existing Chrome)
|
||||
|
||||
Clawdbot can also drive **your existing Chrome tabs** (no separate “clawd” Chrome instance) via a local CDP relay + a Chrome extension.
|
||||
|
||||
Full guide: [Chrome extension](/tools/chrome-extension)
|
||||
|
||||
Flow:
|
||||
- You run a **browser control server** (Gateway on the same machine, or `clawdbot browser serve`).
|
||||
- A local **relay server** listens at a loopback `cdpUrl` (default: `http://127.0.0.1:18792`).
|
||||
- You click the **Clawdbot Browser Relay** extension icon on a tab to attach (it does not auto-attach).
|
||||
- The agent controls that tab via the normal `browser` tool, by selecting the right profile.
|
||||
|
||||
If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need `clawdbot browser serve`.
|
||||
Use `browser serve` only when the Gateway runs elsewhere (remote mode).
|
||||
|
||||
### Sandboxed sessions
|
||||
|
||||
If the agent session is sandboxed, the `browser` tool may default to `target="sandbox"` (sandbox browser).
|
||||
Chrome extension relay takeover requires host browser control, so either:
|
||||
- run the session unsandboxed, or
|
||||
- set `agents.defaults.sandbox.browser.allowHostControl: true` and use `target="host"` when calling the tool.
|
||||
|
||||
### Setup
|
||||
|
||||
1) Create a profile that uses the extension driver:
|
||||
|
||||
```bash
|
||||
clawdbot browser create-profile \
|
||||
--name chrome \
|
||||
--driver extension \
|
||||
--cdp-url http://127.0.0.1:18792 \
|
||||
--color "#00AA00"
|
||||
```
|
||||
|
||||
2) Load the extension (dev/unpacked):
|
||||
- Chrome → `chrome://extensions` → enable “Developer mode”
|
||||
- `clawdbot browser extension install`
|
||||
- “Load unpacked” → select the directory printed by `clawdbot browser extension path`
|
||||
- Pin the extension, then click it on the tab you want to control (badge shows `ON`).
|
||||
|
||||
3) Use it:
|
||||
- CLI: `clawdbot browser --browser-profile chrome tabs`
|
||||
- Agent tool: `browser` with `profile="chrome"`
|
||||
|
||||
Notes:
|
||||
- This mode relies on Playwright-on-CDP for most operations (screenshots/snapshots/actions).
|
||||
- Detach by clicking the extension icon again.
|
||||
|
||||
## Isolation guarantees
|
||||
|
||||
- **Dedicated user data dir**: never touches your personal Chrome profile.
|
||||
@@ -164,7 +301,8 @@ All endpoints accept `?profile=<name>`.
|
||||
|
||||
Some features (navigate/act/AI snapshot/role snapshot, element screenshots, PDF) require
|
||||
Playwright. If Playwright isn’t installed, those endpoints return a clear 501
|
||||
error. ARIA snapshots and basic screenshots still work.
|
||||
error. ARIA snapshots and basic screenshots still work for clawd-managed Chrome.
|
||||
For the Chrome extension relay driver, ARIA snapshots and screenshots require Playwright.
|
||||
|
||||
## How it works (internal)
|
||||
|
||||
@@ -204,6 +342,8 @@ Inspection:
|
||||
- `clawdbot browser snapshot`
|
||||
- `clawdbot browser snapshot --format aria --limit 200`
|
||||
- `clawdbot browser snapshot --interactive --compact --depth 6`
|
||||
- `clawdbot browser snapshot --efficient`
|
||||
- `clawdbot browser snapshot --labels`
|
||||
- `clawdbot browser snapshot --selector "#main" --interactive`
|
||||
- `clawdbot browser snapshot --frame "iframe#main" --interactive`
|
||||
- `clawdbot browser console --level error`
|
||||
@@ -260,9 +400,11 @@ Notes:
|
||||
- `snapshot`:
|
||||
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
|
||||
- `--format aria`: returns the accessibility tree (no refs; inspection only).
|
||||
- `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars).
|
||||
- Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`.
|
||||
- `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`).
|
||||
- `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions).
|
||||
- `--labels` adds a viewport-only screenshot with overlayed ref labels (prints `MEDIA:<path>`).
|
||||
- `click`/`type`/etc require a `ref` from `snapshot` (either numeric `12` or role ref `e12`).
|
||||
CSS selectors are intentionally not supported for actions.
|
||||
|
||||
@@ -279,6 +421,7 @@ Clawdbot supports two “snapshot” styles:
|
||||
- Output: a role-based list/tree with `[ref=e12]` (and optional `[nth=1]`).
|
||||
- Actions: `clawdbot browser click e12`, `clawdbot browser highlight e12`.
|
||||
- Internally, the ref is resolved via `getByRole(...)` (plus `nth()` for duplicates).
|
||||
- Add `--labels` to include a viewport screenshot with overlayed `e12` labels.
|
||||
|
||||
Ref behavior:
|
||||
- Refs are **not stable across navigations**; if something fails, re-run `snapshot` and use a fresh ref.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
---
|
||||
summary: "Chrome extension: let Clawdbot drive your existing Chrome tab"
|
||||
read_when:
|
||||
- You want the agent to drive an existing Chrome tab (toolbar button)
|
||||
- You need remote Gateway + local browser automation via Tailscale
|
||||
- You want to understand the security implications of browser takeover
|
||||
---
|
||||
|
||||
# Chrome extension (browser relay)
|
||||
|
||||
The Clawdbot Chrome extension lets the agent control your **existing Chrome tabs** (your normal Chrome window) instead of launching a separate clawd-managed Chrome profile.
|
||||
|
||||
Attach/detach happens via a **single Chrome toolbar button**.
|
||||
|
||||
## What it is (concept)
|
||||
|
||||
There are three parts:
|
||||
- **Browser control server** (HTTP): the API the agent/tool calls (`browser.controlUrl`)
|
||||
- **Local relay server** (loopback CDP): bridges between the control server and the extension (`http://127.0.0.1:18792` by default)
|
||||
- **Chrome MV3 extension**: attaches to the active tab using `chrome.debugger` and pipes CDP messages to the relay
|
||||
|
||||
Clawdbot then controls the attached tab through the normal `browser` tool surface (selecting the right profile).
|
||||
|
||||
## Install / load (unpacked)
|
||||
|
||||
1) Install the extension to a stable local path:
|
||||
|
||||
```bash
|
||||
clawdbot browser extension install
|
||||
```
|
||||
|
||||
2) Print the installed extension directory path:
|
||||
|
||||
```bash
|
||||
clawdbot browser extension path
|
||||
```
|
||||
|
||||
3) Chrome → `chrome://extensions`
|
||||
- Enable “Developer mode”
|
||||
- “Load unpacked” → select the directory printed above
|
||||
|
||||
4) Pin the extension.
|
||||
|
||||
## Updates (no build step)
|
||||
|
||||
The extension ships inside the Clawdbot release (npm package) as static files. There is no separate “build” step.
|
||||
|
||||
After upgrading Clawdbot:
|
||||
- Re-run `clawdbot browser extension install` to refresh the installed files under your Clawdbot state directory.
|
||||
- Chrome → `chrome://extensions` → click “Reload” on the extension.
|
||||
|
||||
## Create a browser profile for the extension
|
||||
|
||||
```bash
|
||||
clawdbot browser create-profile \
|
||||
--name chrome \
|
||||
--driver extension \
|
||||
--cdp-url http://127.0.0.1:18792 \
|
||||
--color "#00AA00"
|
||||
```
|
||||
|
||||
Then target it:
|
||||
- CLI: `clawdbot browser --browser-profile chrome tabs`
|
||||
- Agent tool: `browser` with `profile="chrome"`
|
||||
|
||||
## Attach / detach (toolbar button)
|
||||
|
||||
- Open the tab you want Clawdbot to control.
|
||||
- Click the extension icon.
|
||||
- Badge shows `ON` when attached.
|
||||
- Click again to detach.
|
||||
|
||||
## Which tab does it control?
|
||||
|
||||
- It does **not** automatically control “whatever tab you’re looking at”.
|
||||
- It controls **only the tab(s) you explicitly attached** by clicking the toolbar button.
|
||||
- To switch: open the other tab and click the extension icon there.
|
||||
|
||||
## Badge + common errors
|
||||
|
||||
- `ON`: attached; Clawdbot can drive that tab.
|
||||
- `…`: connecting to the local relay.
|
||||
- `!`: relay not reachable (most common: browser relay server isn’t running on this machine).
|
||||
|
||||
If you see `!`:
|
||||
- Make sure the Gateway is running locally (default setup), or run `clawdbot browser serve` on this machine (remote gateway setup).
|
||||
- Open the extension Options page; it shows whether the relay is reachable.
|
||||
|
||||
## Do I need `clawdbot browser serve`?
|
||||
|
||||
### Local Gateway (same machine as Chrome) — usually **no**
|
||||
|
||||
If the Gateway is running on the same machine as Chrome and your `browser.controlUrl` is loopback (default),
|
||||
you typically **do not** need `clawdbot browser serve`.
|
||||
|
||||
The Gateway’s built-in browser control server will start on `http://127.0.0.1:18791/` and Clawdbot will
|
||||
auto-start the local relay server when you use a profile with `driver="extension"`.
|
||||
|
||||
### Remote Gateway (Gateway runs elsewhere) — **yes**
|
||||
|
||||
If your Gateway runs on another machine, run `clawdbot browser serve` on the machine that runs Chrome
|
||||
(and publish it via Tailscale Serve / TLS). See the section below.
|
||||
|
||||
## Sandboxing (tool containers)
|
||||
|
||||
If your agent session is sandboxed (`agents.defaults.sandbox.mode != "off"`), the `browser` tool can be restricted:
|
||||
|
||||
- By default, sandboxed sessions often target the **sandbox browser** (`target="sandbox"`), not your host Chrome.
|
||||
- Chrome extension relay takeover requires controlling the **host** browser control server.
|
||||
|
||||
Options:
|
||||
- Easiest: use the extension from a **non-sandboxed** session/agent.
|
||||
- Or allow host browser control for sandboxed sessions:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
browser: {
|
||||
allowHostControl: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then ensure the tool isn’t denied by tool policy, and (if needed) call `browser` with `target="host"`.
|
||||
|
||||
Debugging: `clawdbot sandbox explain`
|
||||
|
||||
## Remote Gateway (recommended: Tailscale Serve)
|
||||
|
||||
Goal: Gateway runs on one machine, but Chrome runs somewhere else.
|
||||
|
||||
On the **browser machine**:
|
||||
|
||||
```bash
|
||||
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
|
||||
tailscale serve https / http://127.0.0.1:18791
|
||||
```
|
||||
|
||||
On the **Gateway machine**:
|
||||
- Set `browser.controlUrl` to the HTTPS Serve URL (MagicDNS/ts.net).
|
||||
- Provide the token (prefer env):
|
||||
|
||||
```bash
|
||||
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
|
||||
```
|
||||
|
||||
Then the agent can drive the browser by calling the remote `browser.controlUrl` API, while the extension + relay stay local on the browser machine.
|
||||
|
||||
## How “extension path” works
|
||||
|
||||
`clawdbot browser extension path` prints the **installed** on-disk directory containing the extension files.
|
||||
|
||||
The CLI intentionally does **not** print a `node_modules` path. Always run `clawdbot browser extension install` first to copy the extension to a stable location under your Clawdbot state directory.
|
||||
|
||||
If you move or delete that install directory, Chrome will mark the extension as broken until you reload it from a valid path.
|
||||
|
||||
## Security implications (read this)
|
||||
|
||||
This is powerful and risky. Treat it like giving the model “hands on your browser”.
|
||||
|
||||
- The extension uses Chrome’s debugger API (`chrome.debugger`). When attached, the model can:
|
||||
- click/type/navigate in that tab
|
||||
- read page content
|
||||
- access whatever the tab’s logged-in session can access
|
||||
- **This is not isolated** like the dedicated clawd-managed profile.
|
||||
- If you attach to your daily-driver profile/tab, you’re granting access to that account state.
|
||||
|
||||
Recommendations:
|
||||
- Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage.
|
||||
- Keep the browser control server tailnet-only (Tailscale) and require a token.
|
||||
- Avoid exposing browser control over LAN (`0.0.0.0`) and avoid Funnel (public).
|
||||
|
||||
Related:
|
||||
- Browser tool overview: [Browser](/tools/browser)
|
||||
- Security audit: [Security](/gateway/security)
|
||||
- Tailscale setup: [Tailscale](/gateway/tailscale)
|
||||
@@ -20,6 +20,7 @@ read_when:
|
||||
- **Host execution**: elevated runs `exec` on the host (bypasses sandbox).
|
||||
- **Unsandboxed agents**: when there is no sandbox to bypass, elevated does not change where `exec` runs.
|
||||
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
|
||||
- **Not skill-scoped**: elevated cannot be limited to a specific skill; it only changes `exec` location.
|
||||
|
||||
Note:
|
||||
- Sandbox on: `/elevated on` runs that `exec` command on the host.
|
||||
|
||||
@@ -68,6 +68,59 @@ Example (global coding profile, messaging-only support agent):
|
||||
}
|
||||
```
|
||||
|
||||
## Provider-specific tool policy
|
||||
|
||||
Use `tools.byProvider` to **further restrict** tools for specific providers
|
||||
(or a single `provider/model`) without changing your global defaults.
|
||||
Per-agent override: `agents.list[].tools.byProvider`.
|
||||
|
||||
This is applied **after** the base tool profile and **before** allow/deny lists,
|
||||
so it can only narrow the tool set.
|
||||
Provider keys accept either `provider` (e.g. `google-antigravity`) or
|
||||
`provider/model` (e.g. `openai/gpt-5.2`).
|
||||
|
||||
Example (keep global coding profile, but minimal tools for Google Antigravity):
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
profile: "coding",
|
||||
byProvider: {
|
||||
"google-antigravity": { profile: "minimal" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example (provider/model-specific allowlist for a flaky endpoint):
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
allow: ["group:fs", "group:runtime", "sessions_list"],
|
||||
byProvider: {
|
||||
"openai/gpt-5.2": { allow: ["group:fs", "sessions_list"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example (agent-specific override for a single provider):
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "support",
|
||||
tools: {
|
||||
byProvider: {
|
||||
"google-antigravity": { allow: ["message", "sessions_list"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tool groups (shorthands)
|
||||
|
||||
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools.
|
||||
@@ -78,6 +131,7 @@ Available groups:
|
||||
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
|
||||
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
|
||||
- `group:memory`: `memory_search`, `memory_get`
|
||||
- `group:web`: `web_search`, `web_fetch`
|
||||
- `group:ui`: `browser`, `canvas`
|
||||
- `group:automation`: `cron`, `gateway`
|
||||
- `group:messaging`: `message`
|
||||
@@ -135,6 +189,33 @@ Notes:
|
||||
- `log` supports line-based `offset`/`limit` (omit `offset` to grab the last N lines).
|
||||
- `process` is scoped per agent; sessions from other agents are not visible.
|
||||
|
||||
### `web_search`
|
||||
Search the web using Brave Search API.
|
||||
|
||||
Core parameters:
|
||||
- `query` (required)
|
||||
- `count` (1–10; default from `tools.web.search.maxResults`)
|
||||
|
||||
Notes:
|
||||
- Requires a Brave API key (recommended: `clawdbot configure --section web`, or set `BRAVE_API_KEY`).
|
||||
- Enable via `tools.web.search.enabled`.
|
||||
- Responses are cached (default 15 min).
|
||||
- See [Web tools](/tools/web) for setup.
|
||||
|
||||
### `web_fetch`
|
||||
Fetch and extract readable content from a URL (HTML → markdown/text).
|
||||
|
||||
Core parameters:
|
||||
- `url` (required)
|
||||
- `extractMode` (`markdown` | `text`)
|
||||
- `maxChars` (truncate long pages)
|
||||
|
||||
Notes:
|
||||
- Enable via `tools.web.fetch.enabled`.
|
||||
- Responses are cached (default 15 min).
|
||||
- For JS-heavy sites, prefer the browser tool.
|
||||
- See [Web tools](/tools/web) for setup.
|
||||
|
||||
### `browser`
|
||||
Control the dedicated clawd browser.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ All skills-related configuration lives under `skills` in `~/.clawdbot/clawdbot.j
|
||||
```json5
|
||||
{
|
||||
skills: {
|
||||
allowBundled: ["brave-search", "gemini"],
|
||||
allowBundled: ["gemini", "peekaboo"],
|
||||
load: {
|
||||
extraDirs: [
|
||||
"~/Projects/agent-scripts/skills",
|
||||
|
||||
@@ -105,6 +105,13 @@ Fields under `metadata.clawdbot`:
|
||||
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
|
||||
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv).
|
||||
|
||||
Note on sandboxing:
|
||||
- `requires.bins` is checked on the **host** at skill load time.
|
||||
- If an agent is sandboxed, the binary must also exist **inside the container**.
|
||||
Install it via `agents.defaults.sandbox.docker.setupCommand` (or a custom image).
|
||||
Example: the `summarize` skill (`skills/summarize/SKILL.md`) needs the `summarize` CLI
|
||||
in the sandbox container to run there.
|
||||
|
||||
Installer example:
|
||||
|
||||
```markdown
|
||||
|
||||
@@ -49,8 +49,13 @@ Sub-agents report back via an announce step:
|
||||
- The announce step runs inside the sub-agent session (not the requester session).
|
||||
- If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted.
|
||||
- Otherwise the announce reply is posted to the requester chat channel via the gateway `send` method.
|
||||
- Announce messages are normalized to a stable template:
|
||||
- `Status:` derived from the run outcome (`success`, `error`, `timeout`, or `unknown`).
|
||||
- `Result:` the summary content from the announce step (or `(not available)` if missing).
|
||||
- `Notes:` error details and other useful context.
|
||||
- `Status` is not inferred from model output; it comes from runtime outcome signals.
|
||||
|
||||
Announce payloads include a stats line at the end:
|
||||
Announce payloads include a stats line at the end (even when wrapped):
|
||||
- Runtime (e.g., `runtime 5m12s`)
|
||||
- Token usage (input/output/total)
|
||||
- Estimated cost when model pricing is configured (`models.providers.*.models[].cost`)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
summary: "Web search + fetch tools (Brave Search API)"
|
||||
read_when:
|
||||
- You want to enable web_search or web_fetch
|
||||
- You need Brave Search API key setup
|
||||
---
|
||||
|
||||
# Web tools
|
||||
|
||||
Clawdbot ships two lightweight web tools:
|
||||
|
||||
- `web_search` — Brave Search API queries (fast, structured results).
|
||||
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
|
||||
|
||||
These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
[Browser tool](/tools/browser).
|
||||
|
||||
## How it works
|
||||
|
||||
- `web_search` calls Brave’s Search API and returns structured results
|
||||
(title, URL, snippet). No browser is involved.
|
||||
- Results are cached by query for 15 minutes (configurable).
|
||||
- `web_fetch` does a plain HTTP GET and extracts readable content
|
||||
(HTML → markdown/text). It does **not** execute JavaScript.
|
||||
- In sandboxed sessions, `web_fetch` is enabled automatically (unless explicitly disabled).
|
||||
|
||||
## Getting a Brave API key
|
||||
|
||||
1) Create a Brave Search API account at https://brave.com/search/api/
|
||||
2) Generate an API key in the dashboard.
|
||||
3) Run `clawdbot configure --section web` to store the key in config (recommended), or set `BRAVE_API_KEY` in your environment.
|
||||
|
||||
Brave provides a free tier plus paid plans; check the Brave API portal for the
|
||||
current limits and pricing.
|
||||
|
||||
### Where to set the key (recommended)
|
||||
|
||||
**Recommended:** run `clawdbot configure --section web`. It stores the key in
|
||||
`~/.clawdbot/clawdbot.json` under `tools.web.search.apiKey`.
|
||||
|
||||
**Environment alternative:** set `BRAVE_API_KEY` in the Gateway process
|
||||
environment. For a daemon install, put it in `~/.clawdbot/.env` (or your
|
||||
service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables).
|
||||
|
||||
## web_search
|
||||
|
||||
Search the web with Brave’s API.
|
||||
|
||||
### Requirements
|
||||
|
||||
- `tools.web.search.enabled: true`
|
||||
- Brave API key (recommended: `clawdbot configure --section web`, or set `BRAVE_API_KEY`)
|
||||
|
||||
### Config
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
apiKey: "BRAVE_API_KEY_HERE", // optional if BRAVE_API_KEY is set
|
||||
maxResults: 5,
|
||||
timeoutSeconds: 30,
|
||||
cacheTtlMinutes: 15
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tool parameters
|
||||
|
||||
- `query` (required)
|
||||
- `count` (1–10; default from config)
|
||||
|
||||
## web_fetch
|
||||
|
||||
Fetch a URL and extract readable content.
|
||||
|
||||
### Requirements
|
||||
|
||||
- `tools.web.fetch.enabled: true`
|
||||
|
||||
### Config
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
enabled: true,
|
||||
maxChars: 50000,
|
||||
timeoutSeconds: 30,
|
||||
cacheTtlMinutes: 15,
|
||||
userAgent: "clawdbot/2026.1.14"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tool parameters
|
||||
|
||||
- `url` (required, http/https only)
|
||||
- `extractMode` (`markdown` | `text`)
|
||||
- `maxChars` (truncate long pages)
|
||||
|
||||
Notes:
|
||||
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
|
||||
- Responses are cached (default 15 minutes) to reduce repeated fetches.
|
||||
- If you use tool profiles/allowlists, add `web_search`/`web_fetch` or `group:web`.
|
||||
@@ -30,7 +30,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
||||
## What it can do (today)
|
||||
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`)
|
||||
- Stream tool calls + live tool output cards in Chat (agent events)
|
||||
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.set`)
|
||||
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`)
|
||||
- Instances: presence list + refresh (`system-presence`)
|
||||
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
|
||||
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
||||
@@ -38,6 +38,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
||||
- Nodes: list + caps (`node.list`)
|
||||
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
|
||||
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
||||
- Config writes include a base-hash guard to prevent clobbering concurrent edits
|
||||
- Config schema + form rendering (`config.schema`); Raw JSON editor remains available
|
||||
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`)
|
||||
- Logs: live tail of gateway file logs with filter/export (`logs.tail`)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Features
|
||||
- Zalo Bot API channel plugin with token-based auth (env/config/file).
|
||||
- Direct message support (DMs only) with pairing/allowlist/open/disabled policies.
|
||||
- Polling and webhook delivery modes.
|
||||
- Text + image messaging with 2000-char chunking and media size caps.
|
||||
- Multi-account support with per-account config.
|
||||
@@ -0,0 +1,50 @@
|
||||
# @clawdbot/zalo
|
||||
|
||||
Zalo channel plugin for Clawdbot (Bot API).
|
||||
|
||||
## Install (local checkout)
|
||||
|
||||
```bash
|
||||
clawdbot plugins install ./extensions/zalo
|
||||
```
|
||||
|
||||
## Install (npm)
|
||||
|
||||
```bash
|
||||
clawdbot plugins install @clawdbot/zalo
|
||||
```
|
||||
|
||||
Onboarding: select Zalo and confirm the install prompt to fetch the plugin automatically.
|
||||
|
||||
## Config
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalo: {
|
||||
enabled: true,
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "pairing",
|
||||
proxy: "http://proxy.local:8080"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Webhook mode
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalo: {
|
||||
webhookUrl: "https://example.com/zalo-webhook",
|
||||
webhookSecret: "your-secret-8-plus-chars",
|
||||
webhookPath: "/zalo-webhook"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `webhookPath` is omitted, the plugin uses the webhook URL path.
|
||||
|
||||
Restart the gateway after config changes.
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
|
||||
|
||||
import { zaloDock, zaloPlugin } from "./src/channel.js";
|
||||
import { handleZaloWebhookRequest } from "./src/monitor.js";
|
||||
|
||||
const plugin = {
|
||||
id: "zalo",
|
||||
name: "Zalo",
|
||||
description: "Zalo channel plugin (Bot API)",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
|
||||
api.registerHttpHandler(handleZaloWebhookRequest);
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@clawdbot/zalo",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Zalo channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
},
|
||||
"dependencies": {
|
||||
"undici": "7.18.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
CoreConfig,
|
||||
ResolvedZaloAccount,
|
||||
ZaloAccountConfig,
|
||||
ZaloConfig,
|
||||
} from "./types.js";
|
||||
import { resolveZaloToken } from "./token.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./shared/account-ids.js";
|
||||
|
||||
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
|
||||
const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return [];
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listZaloAccountIds(cfg: CoreConfig): string[] {
|
||||
const ids = listConfiguredAccountIds(cfg);
|
||||
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
||||
return ids.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveDefaultZaloAccountId(cfg: CoreConfig): string {
|
||||
const zaloConfig = cfg.channels?.zalo as ZaloConfig | undefined;
|
||||
if (zaloConfig?.defaultAccount?.trim()) return zaloConfig.defaultAccount.trim();
|
||||
const ids = listZaloAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
): ZaloAccountConfig | undefined {
|
||||
const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return undefined;
|
||||
return accounts[accountId] as ZaloAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeZaloAccountConfig(cfg: CoreConfig, accountId: string): ZaloAccountConfig {
|
||||
const raw = (cfg.channels?.zalo ?? {}) as ZaloConfig;
|
||||
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
export function resolveZaloAccount(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedZaloAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = (params.cfg.channels?.zalo as ZaloConfig | undefined)?.enabled !== false;
|
||||
const merged = mergeZaloAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const tokenResolution = resolveZaloToken(
|
||||
params.cfg.channels?.zalo as ZaloConfig | undefined,
|
||||
accountId,
|
||||
);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: merged.name?.trim() || undefined,
|
||||
enabled,
|
||||
token: tokenResolution.token,
|
||||
tokenSource: tokenResolution.source,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledZaloAccounts(cfg: CoreConfig): ResolvedZaloAccount[] {
|
||||
return listZaloAccountIds(cfg)
|
||||
.map((accountId) => resolveZaloAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../../src/channels/plugins/types.js";
|
||||
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import { listEnabledZaloAccounts } from "./accounts.js";
|
||||
import { sendMessageZalo } from "./send.js";
|
||||
import { jsonResult, readStringParam } from "./tool-helpers.js";
|
||||
|
||||
const providerId = "zalo";
|
||||
|
||||
function listEnabledAccounts(cfg: CoreConfig) {
|
||||
return listEnabledZaloAccounts(cfg).filter(
|
||||
(account) => account.enabled && account.tokenSource !== "none",
|
||||
);
|
||||
}
|
||||
|
||||
export const zaloMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
const accounts = listEnabledAccounts(cfg as CoreConfig);
|
||||
if (accounts.length === 0) return [];
|
||||
const actions = new Set<ChannelMessageActionName>(["send"]);
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsButtons: () => false,
|
||||
extractToolSend: ({ args }) => {
|
||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||
if (action !== "sendMessage") return null;
|
||||
const to = typeof args.to === "string" ? args.to : undefined;
|
||||
if (!to) return null;
|
||||
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
||||
return { to, accountId };
|
||||
},
|
||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||
if (action === "send") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "message", {
|
||||
required: true,
|
||||
allowEmpty: true,
|
||||
});
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
|
||||
const result = await sendMessageZalo(to ?? "", content ?? "", {
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
cfg: cfg as CoreConfig,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
error: result.error ?? "Failed to send Zalo message",
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResult({ ok: true, to, messageId: result.messageId });
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Zalo Bot API client
|
||||
* @see https://bot.zaloplatforms.com/docs
|
||||
*/
|
||||
|
||||
const ZALO_API_BASE = "https://bot-api.zaloplatforms.com";
|
||||
|
||||
export type ZaloFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export type ZaloApiResponse<T = unknown> = {
|
||||
ok: boolean;
|
||||
result?: T;
|
||||
error_code?: number;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type ZaloBotInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type ZaloMessage = {
|
||||
message_id: string;
|
||||
from: {
|
||||
id: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
chat: {
|
||||
id: string;
|
||||
chat_type: "PRIVATE" | "GROUP";
|
||||
};
|
||||
date: number;
|
||||
text?: string;
|
||||
photo?: string;
|
||||
caption?: string;
|
||||
sticker?: string;
|
||||
};
|
||||
|
||||
export type ZaloUpdate = {
|
||||
event_name:
|
||||
| "message.text.received"
|
||||
| "message.image.received"
|
||||
| "message.sticker.received"
|
||||
| "message.unsupported.received";
|
||||
message?: ZaloMessage;
|
||||
};
|
||||
|
||||
export type ZaloSendMessageParams = {
|
||||
chat_id: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type ZaloSendPhotoParams = {
|
||||
chat_id: string;
|
||||
photo: string;
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export type ZaloSetWebhookParams = {
|
||||
url: string;
|
||||
secret_token: string;
|
||||
};
|
||||
|
||||
export type ZaloGetUpdatesParams = {
|
||||
/** Timeout in seconds (passed as string to API) */
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export class ZaloApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly errorCode?: number,
|
||||
public readonly description?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ZaloApiError";
|
||||
}
|
||||
|
||||
/** True if this is a long-polling timeout (no updates available) */
|
||||
get isPollingTimeout(): boolean {
|
||||
return this.errorCode === 408;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the Zalo Bot API
|
||||
*/
|
||||
export async function callZaloApi<T = unknown>(
|
||||
method: string,
|
||||
token: string,
|
||||
body?: Record<string, unknown>,
|
||||
options?: { timeoutMs?: number; fetch?: ZaloFetch },
|
||||
): Promise<ZaloApiResponse<T>> {
|
||||
const url = `${ZALO_API_BASE}/bot${token}/${method}`;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = options?.timeoutMs
|
||||
? setTimeout(() => controller.abort(), options.timeoutMs)
|
||||
: undefined;
|
||||
const fetcher = options?.fetch ?? fetch;
|
||||
|
||||
try {
|
||||
const response = await fetcher(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const data = (await response.json()) as ZaloApiResponse<T>;
|
||||
|
||||
if (!data.ok) {
|
||||
throw new ZaloApiError(
|
||||
data.description ?? `Zalo API error: ${method}`,
|
||||
data.error_code,
|
||||
data.description,
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate bot token and get bot info
|
||||
*/
|
||||
export async function getMe(
|
||||
token: string,
|
||||
timeoutMs?: number,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<ZaloBotInfo>> {
|
||||
return callZaloApi<ZaloBotInfo>("getMe", token, undefined, { timeoutMs, fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a text message
|
||||
*/
|
||||
export async function sendMessage(
|
||||
token: string,
|
||||
params: ZaloSendMessageParams,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<ZaloMessage>> {
|
||||
return callZaloApi<ZaloMessage>("sendMessage", token, params, { fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a photo message
|
||||
*/
|
||||
export async function sendPhoto(
|
||||
token: string,
|
||||
params: ZaloSendPhotoParams,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<ZaloMessage>> {
|
||||
return callZaloApi<ZaloMessage>("sendPhoto", token, params, { fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get updates using long polling (dev/testing only)
|
||||
* Note: Zalo returns a single update per call, not an array like Telegram
|
||||
*/
|
||||
export async function getUpdates(
|
||||
token: string,
|
||||
params?: ZaloGetUpdatesParams,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<ZaloUpdate>> {
|
||||
const pollTimeoutSec = params?.timeout ?? 30;
|
||||
const timeoutMs = (pollTimeoutSec + 5) * 1000;
|
||||
const body = { timeout: String(pollTimeoutSec) };
|
||||
return callZaloApi<ZaloUpdate>("getUpdates", token, body, { timeoutMs, fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set webhook URL for receiving updates
|
||||
*/
|
||||
export async function setWebhook(
|
||||
token: string,
|
||||
params: ZaloSetWebhookParams,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<boolean>> {
|
||||
return callZaloApi<boolean>("setWebhook", token, params, { fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete webhook configuration
|
||||
*/
|
||||
export async function deleteWebhook(
|
||||
token: string,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<boolean>> {
|
||||
return callZaloApi<boolean>("deleteWebhook", token, undefined, { fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current webhook info
|
||||
*/
|
||||
export async function getWebhookInfo(
|
||||
token: string,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<{ url?: string; has_custom_certificate?: boolean }>> {
|
||||
return callZaloApi("getWebhookInfo", token, undefined, { fetch: fetcher });
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import type { ChannelDock, ChannelPlugin } from "../../src/channels/plugins/types.js";
|
||||
import type { ChannelAccountSnapshot } from "../../src/channels/plugins/types.js";
|
||||
|
||||
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount, type ResolvedZaloAccount } from "./accounts.js";
|
||||
import { zaloMessageActions } from "./actions.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "./shared/channel-config.js";
|
||||
import { zaloOnboardingAdapter } from "./onboarding.js";
|
||||
import { formatPairingApproveHint, PAIRING_APPROVED_MESSAGE } from "./shared/pairing.js";
|
||||
import { resolveZaloProxyFetch } from "./proxy.js";
|
||||
import { probeZalo } from "./probe.js";
|
||||
import { sendMessageZalo } from "./send.js";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "./shared/channel-setup.js";
|
||||
import { collectZaloStatusIssues } from "./status-issues.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./shared/account-ids.js";
|
||||
|
||||
const meta = {
|
||||
id: "zalo",
|
||||
label: "Zalo",
|
||||
selectionLabel: "Zalo (Bot API)",
|
||||
docsPath: "/channels/zalo",
|
||||
docsLabel: "zalo",
|
||||
blurb: "Vietnam-focused messaging platform with Bot API.",
|
||||
aliases: ["zl"],
|
||||
order: 80,
|
||||
quickstartAllowFrom: true,
|
||||
};
|
||||
|
||||
|
||||
function normalizeZaloMessagingTarget(raw: string): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.replace(/^(zalo|zl):/i, "");
|
||||
}
|
||||
|
||||
export const zaloDock: ChannelDock = {
|
||||
id: "zalo",
|
||||
capabilities: {
|
||||
chatTypes: ["direct"],
|
||||
media: true,
|
||||
blockStreaming: true,
|
||||
},
|
||||
outbound: { textChunkLimit: 2000 },
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
||||
(entry) => String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => entry.replace(/^(zalo|zl):/i, ""))
|
||||
.map((entry) => entry.toLowerCase()),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
},
|
||||
};
|
||||
|
||||
export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
||||
id: "zalo",
|
||||
meta,
|
||||
onboarding: zaloOnboardingAdapter,
|
||||
capabilities: {
|
||||
chatTypes: ["direct"],
|
||||
media: true,
|
||||
reactions: false,
|
||||
threads: false,
|
||||
polls: false,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.zalo"] },
|
||||
config: {
|
||||
listAccountIds: (cfg) => listZaloAccountIds(cfg as CoreConfig),
|
||||
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg as CoreConfig),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
sectionKey: "zalo",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
sectionKey: "zalo",
|
||||
accountId,
|
||||
clearBaseFields: ["botToken", "tokenFile", "name"],
|
||||
}),
|
||||
isConfigured: (account) => Boolean(account.token?.trim()),
|
||||
describeAccount: (account): ChannelAccountSnapshot => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.token?.trim()),
|
||||
tokenSource: account.tokenSource,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
||||
(entry) => String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => entry.replace(/^(zalo|zl):/i, ""))
|
||||
.map((entry) => entry.toLowerCase()),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const useAccountPath = Boolean(
|
||||
(cfg as CoreConfig).channels?.zalo?.accounts?.[resolvedAccountId],
|
||||
);
|
||||
const basePath = useAccountPath
|
||||
? `channels.zalo.accounts.${resolvedAccountId}.`
|
||||
: "channels.zalo.";
|
||||
return {
|
||||
policy: account.config.dmPolicy ?? "pairing",
|
||||
allowFrom: account.config.allowFrom ?? [],
|
||||
policyPath: `${basePath}dmPolicy`,
|
||||
allowFromPath: basePath,
|
||||
approveHint: formatPairingApproveHint("zalo"),
|
||||
normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
|
||||
};
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
},
|
||||
actions: zaloMessageActions,
|
||||
messaging: {
|
||||
normalizeTarget: normalizeZaloMessagingTarget,
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
channelKey: "zalo",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ accountId, input }) => {
|
||||
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return "ZALO_BOT_TOKEN can only be used for the default account.";
|
||||
}
|
||||
if (!input.useEnv && !input.token && !input.tokenFile) {
|
||||
return "Zalo requires --token or --token-file (or --use-env).";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
channelKey: "zalo",
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const next =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig,
|
||||
channelKey: "zalo",
|
||||
})
|
||||
: namedConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalo: {
|
||||
...next.channels?.zalo,
|
||||
enabled: true,
|
||||
...(input.useEnv
|
||||
? {}
|
||||
: input.tokenFile
|
||||
? { tokenFile: input.tokenFile }
|
||||
: input.token
|
||||
? { botToken: input.token }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalo: {
|
||||
...next.channels?.zalo,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(next.channels?.zalo?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(next.channels?.zalo?.accounts?.[accountId] ?? {}),
|
||||
enabled: true,
|
||||
...(input.tokenFile
|
||||
? { tokenFile: input.tokenFile }
|
||||
: input.token
|
||||
? { botToken: input.token }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
idLabel: "zaloUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const account = resolveZaloAccount({ cfg: cfg as CoreConfig });
|
||||
if (!account.token) throw new Error("Zalo token not configured");
|
||||
await sendMessageZalo(id, PAIRING_APPROVED_MESSAGE, { token: account.token });
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => {
|
||||
if (!text) return [];
|
||||
if (limit <= 0 || text.length <= limit) return [text];
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > limit) {
|
||||
const window = remaining.slice(0, limit);
|
||||
const lastNewline = window.lastIndexOf("\n");
|
||||
const lastSpace = window.lastIndexOf(" ");
|
||||
let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
|
||||
if (breakIdx <= 0) breakIdx = limit;
|
||||
const rawChunk = remaining.slice(0, breakIdx);
|
||||
const chunk = rawChunk.trimEnd();
|
||||
if (chunk.length > 0) chunks.push(chunk);
|
||||
const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
||||
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
|
||||
remaining = remaining.slice(nextStart).trimStart();
|
||||
}
|
||||
if (remaining.length) chunks.push(remaining);
|
||||
return chunks;
|
||||
},
|
||||
textChunkLimit: 2000,
|
||||
resolveTarget: ({ to }) => {
|
||||
const trimmed = to?.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error("Delivering to Zalo requires --to <chatId>"),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
sendText: async ({ to, text, accountId, cfg }) => {
|
||||
const result = await sendMessageZalo(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
cfg: cfg as CoreConfig,
|
||||
});
|
||||
return {
|
||||
channel: "zalo",
|
||||
ok: result.ok,
|
||||
messageId: result.messageId ?? "",
|
||||
error: result.error ? new Error(result.error) : undefined,
|
||||
};
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
|
||||
const result = await sendMessageZalo(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl,
|
||||
cfg: cfg as CoreConfig,
|
||||
});
|
||||
return {
|
||||
channel: "zalo",
|
||||
ok: result.ok,
|
||||
messageId: result.messageId ?? "",
|
||||
error: result.error ? new Error(result.error) : undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: collectZaloStatusIssues,
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
tokenSource: snapshot.tokenSource ?? "none",
|
||||
running: snapshot.running ?? false,
|
||||
mode: snapshot.mode ?? null,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
|
||||
buildAccountSnapshot: ({ account, runtime }) => {
|
||||
const configured = Boolean(account.token?.trim());
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
tokenSource: account.tokenSource,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
mode: account.config.webhookUrl ? "webhook" : "polling",
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
};
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const token = account.token.trim();
|
||||
let zaloBotLabel = "";
|
||||
const fetcher = resolveZaloProxyFetch(account.config.proxy);
|
||||
try {
|
||||
const probe = await probeZalo(token, 2500, fetcher);
|
||||
const name = probe.ok ? probe.bot?.name?.trim() : null;
|
||||
if (name) zaloBotLabel = ` (${name})`;
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
bot: probe.bot,
|
||||
});
|
||||
} catch {
|
||||
// ignore probe errors
|
||||
}
|
||||
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel}`);
|
||||
const { monitorZaloProvider } = await import("./monitor.js");
|
||||
return monitorZaloProvider({
|
||||
token,
|
||||
account,
|
||||
config: ctx.cfg as CoreConfig,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
useWebhook: Boolean(account.config.webhookUrl),
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
webhookSecret: account.config.webhookSecret,
|
||||
webhookPath: account.config.webhookPath,
|
||||
fetcher,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
export type CoreChannelDeps = {
|
||||
chunkMarkdownText: (text: string, limit: number) => string[];
|
||||
formatAgentEnvelope: (params: {
|
||||
channel: string;
|
||||
from: string;
|
||||
timestamp?: number;
|
||||
body: string;
|
||||
}) => string;
|
||||
dispatchReplyWithBufferedBlockDispatcher: (params: {
|
||||
ctx: unknown;
|
||||
cfg: unknown;
|
||||
dispatcherOptions: {
|
||||
deliver: (payload: unknown) => Promise<void>;
|
||||
onError?: (err: unknown, info: { kind: string }) => void;
|
||||
};
|
||||
}) => Promise<void>;
|
||||
resolveAgentRoute: (params: {
|
||||
cfg: unknown;
|
||||
channel: string;
|
||||
accountId: string;
|
||||
peer: { kind: "dm" | "group" | "channel"; id: string };
|
||||
}) => { sessionKey: string; accountId: string };
|
||||
buildPairingReply: (params: { channel: string; idLine: string; code: string }) => string;
|
||||
readChannelAllowFromStore: (channel: string) => Promise<string[]>;
|
||||
upsertChannelPairingRequest: (params: {
|
||||
channel: string;
|
||||
id: string;
|
||||
meta?: { name?: string };
|
||||
}) => Promise<{ code: string; created: boolean }>;
|
||||
fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>;
|
||||
saveMediaBuffer: (
|
||||
buffer: Buffer,
|
||||
contentType: string | undefined,
|
||||
type: "inbound" | "outbound",
|
||||
maxBytes: number,
|
||||
) => Promise<{ path: string; contentType: string }>;
|
||||
shouldLogVerbose: () => boolean;
|
||||
};
|
||||
|
||||
let coreRootCache: string | null = null;
|
||||
let coreDepsPromise: Promise<CoreChannelDeps> | null = null;
|
||||
|
||||
function findPackageRoot(startDir: string, name: string): string | null {
|
||||
let dir = startDir;
|
||||
for (;;) {
|
||||
const pkgPath = path.join(dir, "package.json");
|
||||
try {
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const raw = fs.readFileSync(pkgPath, "utf8");
|
||||
const pkg = JSON.parse(raw) as { name?: string };
|
||||
if (pkg.name === name) return dir;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClawdbotRoot(): string {
|
||||
if (coreRootCache) return coreRootCache;
|
||||
const override = process.env.CLAWDBOT_ROOT?.trim();
|
||||
if (override) {
|
||||
coreRootCache = override;
|
||||
return override;
|
||||
}
|
||||
|
||||
const candidates = new Set<string>();
|
||||
if (process.argv[1]) {
|
||||
candidates.add(path.dirname(process.argv[1]));
|
||||
}
|
||||
candidates.add(process.cwd());
|
||||
try {
|
||||
const urlPath = fileURLToPath(import.meta.url);
|
||||
candidates.add(path.dirname(urlPath));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
for (const start of candidates) {
|
||||
const found = findPackageRoot(start, "clawdbot");
|
||||
if (found) {
|
||||
coreRootCache = found;
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unable to resolve Clawdbot root. Set CLAWDBOT_ROOT to the package root.",
|
||||
);
|
||||
}
|
||||
|
||||
async function importCoreModule<T>(relativePath: string): Promise<T> {
|
||||
const root = resolveClawdbotRoot();
|
||||
const distPath = path.join(root, "dist", relativePath);
|
||||
if (!fs.existsSync(distPath)) {
|
||||
throw new Error(
|
||||
`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
|
||||
);
|
||||
}
|
||||
return (await import(pathToFileURL(distPath).href)) as T;
|
||||
}
|
||||
|
||||
export async function loadCoreChannelDeps(): Promise<CoreChannelDeps> {
|
||||
if (coreDepsPromise) return coreDepsPromise;
|
||||
|
||||
coreDepsPromise = (async () => {
|
||||
const [
|
||||
chunk,
|
||||
envelope,
|
||||
dispatcher,
|
||||
routing,
|
||||
pairingMessages,
|
||||
pairingStore,
|
||||
mediaFetch,
|
||||
mediaStore,
|
||||
globals,
|
||||
] = await Promise.all([
|
||||
importCoreModule<{ chunkMarkdownText: CoreChannelDeps["chunkMarkdownText"] }>(
|
||||
"auto-reply/chunk.js",
|
||||
),
|
||||
importCoreModule<{ formatAgentEnvelope: CoreChannelDeps["formatAgentEnvelope"] }>(
|
||||
"auto-reply/envelope.js",
|
||||
),
|
||||
importCoreModule<{
|
||||
dispatchReplyWithBufferedBlockDispatcher: CoreChannelDeps["dispatchReplyWithBufferedBlockDispatcher"];
|
||||
}>("auto-reply/reply/provider-dispatcher.js"),
|
||||
importCoreModule<{ resolveAgentRoute: CoreChannelDeps["resolveAgentRoute"] }>(
|
||||
"routing/resolve-route.js",
|
||||
),
|
||||
importCoreModule<{ buildPairingReply: CoreChannelDeps["buildPairingReply"] }>(
|
||||
"pairing/pairing-messages.js",
|
||||
),
|
||||
importCoreModule<{
|
||||
readChannelAllowFromStore: CoreChannelDeps["readChannelAllowFromStore"];
|
||||
upsertChannelPairingRequest: CoreChannelDeps["upsertChannelPairingRequest"];
|
||||
}>("pairing/pairing-store.js"),
|
||||
importCoreModule<{ fetchRemoteMedia: CoreChannelDeps["fetchRemoteMedia"] }>(
|
||||
"media/fetch.js",
|
||||
),
|
||||
importCoreModule<{ saveMediaBuffer: CoreChannelDeps["saveMediaBuffer"] }>(
|
||||
"media/store.js",
|
||||
),
|
||||
importCoreModule<{ shouldLogVerbose: CoreChannelDeps["shouldLogVerbose"] }>(
|
||||
"globals.js",
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
chunkMarkdownText: chunk.chunkMarkdownText,
|
||||
formatAgentEnvelope: envelope.formatAgentEnvelope,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
dispatcher.dispatchReplyWithBufferedBlockDispatcher,
|
||||
resolveAgentRoute: routing.resolveAgentRoute,
|
||||
buildPairingReply: pairingMessages.buildPairingReply,
|
||||
readChannelAllowFromStore: pairingStore.readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest: pairingStore.upsertChannelPairingRequest,
|
||||
fetchRemoteMedia: mediaFetch.fetchRemoteMedia,
|
||||
saveMediaBuffer: mediaStore.saveMediaBuffer,
|
||||
shouldLogVerbose: globals.shouldLogVerbose,
|
||||
};
|
||||
})();
|
||||
|
||||
return coreDepsPromise;
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
import {
|
||||
ZaloApiError,
|
||||
deleteWebhook,
|
||||
getUpdates,
|
||||
sendMessage,
|
||||
sendPhoto,
|
||||
setWebhook,
|
||||
type ZaloFetch,
|
||||
type ZaloMessage,
|
||||
type ZaloUpdate,
|
||||
} from "./api.js";
|
||||
import { loadCoreChannelDeps } from "./core-bridge.js";
|
||||
import { resolveZaloProxyFetch } from "./proxy.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
export type ZaloRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
};
|
||||
|
||||
export type ZaloMonitorOptions = {
|
||||
token: string;
|
||||
account: ResolvedZaloAccount;
|
||||
config: CoreConfig;
|
||||
runtime: ZaloRuntimeEnv;
|
||||
abortSignal: AbortSignal;
|
||||
useWebhook?: boolean;
|
||||
webhookUrl?: string;
|
||||
webhookSecret?: string;
|
||||
webhookPath?: string;
|
||||
fetcher?: ZaloFetch;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
};
|
||||
|
||||
export type ZaloMonitorResult = {
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
const ZALO_TEXT_LIMIT = 2000;
|
||||
const DEFAULT_MEDIA_MAX_MB = 5;
|
||||
|
||||
function logVerbose(deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>, message: string): void {
|
||||
if (deps.shouldLogVerbose()) {
|
||||
console.log(`[zalo] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
|
||||
if (allowFrom.includes("*")) return true;
|
||||
const normalizedSenderId = senderId.toLowerCase();
|
||||
return allowFrom.some((entry) => {
|
||||
const normalized = entry.toLowerCase().replace(/^(zalo|zl):/i, "");
|
||||
return normalized === normalizedSenderId;
|
||||
});
|
||||
}
|
||||
|
||||
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
resolve({ ok: false, error: "payload too large" });
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
if (!raw.trim()) {
|
||||
resolve({ ok: false, error: "empty payload" });
|
||||
return;
|
||||
}
|
||||
resolve({ ok: true, value: JSON.parse(raw) as unknown });
|
||||
} catch (err) {
|
||||
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type WebhookTarget = {
|
||||
token: string;
|
||||
account: ResolvedZaloAccount;
|
||||
config: CoreConfig;
|
||||
runtime: ZaloRuntimeEnv;
|
||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
|
||||
secret: string;
|
||||
path: string;
|
||||
mediaMaxMb: number;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
fetcher?: ZaloFetch;
|
||||
};
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
|
||||
function normalizeWebhookPath(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return "/";
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
||||
return withSlash.slice(0, -1);
|
||||
}
|
||||
return withSlash;
|
||||
}
|
||||
|
||||
function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null {
|
||||
const trimmedPath = webhookPath?.trim();
|
||||
if (trimmedPath) return normalizeWebhookPath(trimmedPath);
|
||||
if (webhookUrl?.trim()) {
|
||||
try {
|
||||
const parsed = new URL(webhookUrl);
|
||||
return normalizeWebhookPath(parsed.pathname || "/");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
|
||||
const key = normalizeWebhookPath(target.path);
|
||||
const normalizedTarget = { ...target, path: key };
|
||||
const existing = webhookTargets.get(key) ?? [];
|
||||
const next = [...existing, normalizedTarget];
|
||||
webhookTargets.set(key, next);
|
||||
return () => {
|
||||
const updated = (webhookTargets.get(key) ?? []).filter(
|
||||
(entry) => entry !== normalizedTarget,
|
||||
);
|
||||
if (updated.length > 0) {
|
||||
webhookTargets.set(key, updated);
|
||||
} else {
|
||||
webhookTargets.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleZaloWebhookRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const path = normalizeWebhookPath(url.pathname);
|
||||
const targets = webhookTargets.get(path);
|
||||
if (!targets || targets.length === 0) return false;
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "POST");
|
||||
res.end("Method Not Allowed");
|
||||
return true;
|
||||
}
|
||||
|
||||
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
|
||||
const target = targets.find((entry) => entry.secret === headerToken);
|
||||
if (!target) {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await readJsonBody(req, 1024 * 1024);
|
||||
if (!body.ok) {
|
||||
res.statusCode = body.error === "payload too large" ? 413 : 400;
|
||||
res.end(body.error ?? "invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = body.value as { ok?: boolean; result?: ZaloUpdate };
|
||||
if (!payload?.ok || !payload.result) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
target.statusSink?.({ lastInboundAt: Date.now() });
|
||||
processUpdate(
|
||||
payload.result,
|
||||
target.token,
|
||||
target.account,
|
||||
target.config,
|
||||
target.runtime,
|
||||
target.deps,
|
||||
target.mediaMaxMb,
|
||||
target.statusSink,
|
||||
target.fetcher,
|
||||
).catch((err) => {
|
||||
target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
return true;
|
||||
}
|
||||
|
||||
function startPollingLoop(params: {
|
||||
token: string;
|
||||
account: ResolvedZaloAccount;
|
||||
config: CoreConfig;
|
||||
runtime: ZaloRuntimeEnv;
|
||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
|
||||
abortSignal: AbortSignal;
|
||||
isStopped: () => boolean;
|
||||
mediaMaxMb: number;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
fetcher?: ZaloFetch;
|
||||
}) {
|
||||
const {
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
abortSignal,
|
||||
isStopped,
|
||||
mediaMaxMb,
|
||||
statusSink,
|
||||
fetcher,
|
||||
} = params;
|
||||
const pollTimeout = 30;
|
||||
|
||||
const poll = async () => {
|
||||
if (isStopped() || abortSignal.aborted) return;
|
||||
|
||||
try {
|
||||
const response = await getUpdates(token, { timeout: pollTimeout }, fetcher);
|
||||
if (response.ok && response.result) {
|
||||
statusSink?.({ lastInboundAt: Date.now() });
|
||||
await processUpdate(
|
||||
response.result,
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
mediaMaxMb,
|
||||
statusSink,
|
||||
fetcher,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ZaloApiError && err.isPollingTimeout) {
|
||||
// no updates
|
||||
} else if (!isStopped() && !abortSignal.aborted) {
|
||||
console.error(`[${account.accountId}] Zalo polling error:`, err);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isStopped() && !abortSignal.aborted) {
|
||||
setImmediate(poll);
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
}
|
||||
|
||||
async function processUpdate(
|
||||
update: ZaloUpdate,
|
||||
token: string,
|
||||
account: ResolvedZaloAccount,
|
||||
config: CoreConfig,
|
||||
runtime: ZaloRuntimeEnv,
|
||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
|
||||
mediaMaxMb: number,
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<void> {
|
||||
const { event_name, message } = update;
|
||||
if (!message) return;
|
||||
|
||||
switch (event_name) {
|
||||
case "message.text.received":
|
||||
await handleTextMessage(
|
||||
message,
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
statusSink,
|
||||
fetcher,
|
||||
);
|
||||
break;
|
||||
case "message.image.received":
|
||||
await handleImageMessage(
|
||||
message,
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
mediaMaxMb,
|
||||
statusSink,
|
||||
fetcher,
|
||||
);
|
||||
break;
|
||||
case "message.sticker.received":
|
||||
console.log(`[${account.accountId}] Received sticker from ${message.from.id}`);
|
||||
break;
|
||||
case "message.unsupported.received":
|
||||
console.log(
|
||||
`[${account.accountId}] Received unsupported message type from ${message.from.id}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTextMessage(
|
||||
message: ZaloMessage,
|
||||
token: string,
|
||||
account: ResolvedZaloAccount,
|
||||
config: CoreConfig,
|
||||
runtime: ZaloRuntimeEnv,
|
||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<void> {
|
||||
const { text } = message;
|
||||
if (!text?.trim()) return;
|
||||
|
||||
await processMessageWithPipeline({
|
||||
message,
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
text,
|
||||
mediaPath: undefined,
|
||||
mediaType: undefined,
|
||||
statusSink,
|
||||
fetcher,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleImageMessage(
|
||||
message: ZaloMessage,
|
||||
token: string,
|
||||
account: ResolvedZaloAccount,
|
||||
config: CoreConfig,
|
||||
runtime: ZaloRuntimeEnv,
|
||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
|
||||
mediaMaxMb: number,
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<void> {
|
||||
const { photo, caption } = message;
|
||||
|
||||
let mediaPath: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
|
||||
if (photo) {
|
||||
try {
|
||||
const maxBytes = mediaMaxMb * 1024 * 1024;
|
||||
const fetched = await deps.fetchRemoteMedia({ url: photo });
|
||||
const saved = await deps.saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
fetched.contentType,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
);
|
||||
mediaPath = saved.path;
|
||||
mediaType = saved.contentType;
|
||||
} catch (err) {
|
||||
console.error(`[${account.accountId}] Failed to download Zalo image:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
await processMessageWithPipeline({
|
||||
message,
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
text: caption,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
statusSink,
|
||||
fetcher,
|
||||
});
|
||||
}
|
||||
|
||||
async function processMessageWithPipeline(params: {
|
||||
message: ZaloMessage;
|
||||
token: string;
|
||||
account: ResolvedZaloAccount;
|
||||
config: CoreConfig;
|
||||
runtime: ZaloRuntimeEnv;
|
||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
|
||||
text?: string;
|
||||
mediaPath?: string;
|
||||
mediaType?: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
fetcher?: ZaloFetch;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
message,
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
text,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
statusSink,
|
||||
fetcher,
|
||||
} = params;
|
||||
const { from, chat, message_id, date } = message;
|
||||
|
||||
const isGroup = chat.chat_type === "GROUP";
|
||||
const chatId = chat.id;
|
||||
const senderId = from.id;
|
||||
const senderName = from.name;
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
||||
|
||||
if (!isGroup) {
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerbose(deps, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open") {
|
||||
const storeAllowFrom = await deps.readChannelAllowFromStore("zalo").catch(() => []);
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const allowed = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await deps.upsertChannelPairingRequest({
|
||||
channel: "zalo",
|
||||
id: senderId,
|
||||
meta: { name: senderName ?? undefined },
|
||||
});
|
||||
|
||||
if (created) {
|
||||
logVerbose(deps, `zalo pairing request sender=${senderId}`);
|
||||
try {
|
||||
await sendMessage(
|
||||
token,
|
||||
{
|
||||
chat_id: chatId,
|
||||
text: deps.buildPairingReply({
|
||||
channel: "zalo",
|
||||
idLine: `Your Zalo user id: ${senderId}`,
|
||||
code,
|
||||
}),
|
||||
},
|
||||
fetcher,
|
||||
);
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
logVerbose(deps, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerbose(deps, `Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const route = deps.resolveAgentRoute({
|
||||
cfg: config,
|
||||
channel: "zalo",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: chatId,
|
||||
},
|
||||
});
|
||||
|
||||
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
||||
const fromLabel = isGroup
|
||||
? `group:${chatId} from ${senderName || senderId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
const body = deps.formatAgentEnvelope({
|
||||
channel: "Zalo",
|
||||
from: fromLabel,
|
||||
timestamp: date ? date * 1000 : undefined,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const ctxPayload = {
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: isGroup ? `group:${chatId}` : `zalo:${senderId}`,
|
||||
To: `zalo:${chatId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
Provider: "zalo",
|
||||
Surface: "zalo",
|
||||
MessageSid: message_id,
|
||||
MediaPath: mediaPath,
|
||||
MediaType: mediaType,
|
||||
MediaUrl: mediaPath,
|
||||
OriginatingChannel: "zalo",
|
||||
OriginatingTo: `zalo:${chatId}`,
|
||||
};
|
||||
|
||||
await deps.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
deliver: async (payload) => {
|
||||
await deliverZaloReply({
|
||||
payload,
|
||||
token,
|
||||
chatId,
|
||||
runtime,
|
||||
deps,
|
||||
statusSink,
|
||||
fetcher,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverZaloReply(params: {
|
||||
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
|
||||
token: string;
|
||||
chatId: string;
|
||||
runtime: ZaloRuntimeEnv;
|
||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
fetcher?: ZaloFetch;
|
||||
}): Promise<void> {
|
||||
const { payload, token, chatId, runtime, deps, statusSink, fetcher } = params;
|
||||
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
|
||||
if (mediaList.length > 0) {
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? payload.text : undefined;
|
||||
first = false;
|
||||
try {
|
||||
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error?.(`Zalo photo send failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.text) {
|
||||
const chunks = deps.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT);
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error?.(`Zalo message send failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorZaloProvider(
|
||||
options: ZaloMonitorOptions,
|
||||
): Promise<ZaloMonitorResult> {
|
||||
const {
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
abortSignal,
|
||||
useWebhook,
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
webhookPath,
|
||||
statusSink,
|
||||
fetcher: fetcherOverride,
|
||||
} = options;
|
||||
|
||||
const deps = await loadCoreChannelDeps();
|
||||
const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
||||
const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
|
||||
|
||||
let stopped = false;
|
||||
const stopHandlers: Array<() => void> = [];
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
for (const handler of stopHandlers) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
if (useWebhook) {
|
||||
if (!webhookUrl || !webhookSecret) {
|
||||
throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
|
||||
}
|
||||
if (!webhookUrl.startsWith("https://")) {
|
||||
throw new Error("Zalo webhook URL must use HTTPS");
|
||||
}
|
||||
if (webhookSecret.length < 8 || webhookSecret.length > 256) {
|
||||
throw new Error("Zalo webhook secret must be 8-256 characters");
|
||||
}
|
||||
|
||||
const path = resolveWebhookPath(webhookPath, webhookUrl);
|
||||
if (!path) {
|
||||
throw new Error("Zalo webhookPath could not be derived");
|
||||
}
|
||||
|
||||
await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
|
||||
|
||||
const unregister = registerZaloWebhookTarget({
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
path,
|
||||
secret: webhookSecret,
|
||||
statusSink: (patch) => statusSink?.(patch),
|
||||
mediaMaxMb: effectiveMediaMaxMb,
|
||||
fetcher,
|
||||
});
|
||||
stopHandlers.push(unregister);
|
||||
abortSignal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
void deleteWebhook(token, fetcher).catch(() => {});
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
return { stop };
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteWebhook(token, fetcher);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
startPollingLoop({
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
abortSignal,
|
||||
isStopped: () => stopped,
|
||||
mediaMaxMb: effectiveMediaMaxMb,
|
||||
statusSink,
|
||||
fetcher,
|
||||
});
|
||||
|
||||
return { stop };
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../../src/channels/plugins/onboarding-types.js";
|
||||
import type { WizardPrompter } from "../../src/wizard/prompts.js";
|
||||
|
||||
import { addWildcardAllowFrom, promptAccountId } from "./shared/onboarding.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./shared/account-ids.js";
|
||||
import {
|
||||
listZaloAccountIds,
|
||||
resolveDefaultZaloAccountId,
|
||||
resolveZaloAccount,
|
||||
} from "./accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const channel = "zalo" as const;
|
||||
|
||||
type UpdateMode = "polling" | "webhook";
|
||||
|
||||
function setZaloDmPolicy(cfg: CoreConfig, dmPolicy: "pairing" | "allowlist" | "open" | "disabled") {
|
||||
const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.zalo?.allowFrom) : undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
function setZaloUpdateMode(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
mode: UpdateMode,
|
||||
webhookUrl?: string,
|
||||
webhookSecret?: string,
|
||||
webhookPath?: string,
|
||||
): CoreConfig {
|
||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
if (mode === "polling") {
|
||||
if (isDefault) {
|
||||
const {
|
||||
webhookUrl: _url,
|
||||
webhookSecret: _secret,
|
||||
webhookPath: _path,
|
||||
...rest
|
||||
} = cfg.channels?.zalo ?? {};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: rest,
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const existing = accounts[accountId] ?? {};
|
||||
const {
|
||||
webhookUrl: _url,
|
||||
webhookSecret: _secret,
|
||||
webhookPath: _path,
|
||||
...rest
|
||||
} = existing;
|
||||
accounts[accountId] = rest;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
accounts,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
if (isDefault) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
webhookPath,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
accounts[accountId] = {
|
||||
...(accounts[accountId] ?? {}),
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
webhookPath,
|
||||
};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
accounts,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
async function noteZaloTokenHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Open Zalo Bot Platform: https://bot.zaloplatforms.com",
|
||||
"2) Create a bot and get the token",
|
||||
"3) Token looks like 12345689:abc-xyz",
|
||||
"Tip: you can also set ZALO_BOT_TOKEN in your env.",
|
||||
"Docs: https://docs.clawd.bot/channels/zalo",
|
||||
].join("\n"),
|
||||
"Zalo bot token",
|
||||
);
|
||||
}
|
||||
|
||||
async function promptZaloAllowFrom(params: {
|
||||
cfg: CoreConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<CoreConfig> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveZaloAccount({ cfg, accountId });
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
const entry = await prompter.text({
|
||||
message: "Zalo allowFrom (user id)",
|
||||
placeholder: "123456789",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
if (!/^\d+$/.test(raw)) return "Use a numeric Zalo user id";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const normalized = String(entry).trim();
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
normalized,
|
||||
];
|
||||
const unique = [...new Set(merged)];
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(cfg.channels?.zalo?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(cfg.channels?.zalo?.accounts?.[accountId] ?? {}),
|
||||
enabled: cfg.channels?.zalo?.accounts?.[accountId]?.enabled ?? true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Zalo",
|
||||
channel,
|
||||
policyKey: "channels.zalo.dmPolicy",
|
||||
allowFromKey: "channels.zalo.allowFrom",
|
||||
getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing",
|
||||
setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as CoreConfig, policy),
|
||||
};
|
||||
|
||||
export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
dmPolicy,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listZaloAccountIds(cfg as CoreConfig).some((accountId) =>
|
||||
Boolean(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).token),
|
||||
);
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Zalo: ${configured ? "configured" : "needs token"}`],
|
||||
selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly",
|
||||
quickstartScore: configured ? 1 : 10,
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds, forceAllowFrom }) => {
|
||||
const zaloOverride = accountOverrides.zalo?.trim();
|
||||
const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg as CoreConfig);
|
||||
let zaloAccountId = zaloOverride
|
||||
? normalizeAccountId(zaloOverride)
|
||||
: defaultZaloAccountId;
|
||||
if (shouldPromptAccountIds && !zaloOverride) {
|
||||
zaloAccountId = await promptAccountId({
|
||||
cfg: cfg as CoreConfig,
|
||||
prompter,
|
||||
label: "Zalo",
|
||||
currentId: zaloAccountId,
|
||||
listAccountIds: listZaloAccountIds,
|
||||
defaultAccountId: defaultZaloAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg as CoreConfig;
|
||||
const resolvedAccount = resolveZaloAccount({ cfg: next, accountId: zaloAccountId });
|
||||
const accountConfigured = Boolean(resolvedAccount.token);
|
||||
const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID;
|
||||
const canUseEnv = allowEnv && Boolean(process.env.ZALO_BOT_TOKEN?.trim());
|
||||
const hasConfigToken = Boolean(
|
||||
resolvedAccount.config.botToken || resolvedAccount.config.tokenFile,
|
||||
);
|
||||
|
||||
let token: string | null = null;
|
||||
if (!accountConfigured) {
|
||||
await noteZaloTokenHelp(prompter);
|
||||
}
|
||||
if (canUseEnv && !resolvedAccount.config.botToken) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "ZALO_BOT_TOKEN detected. Use env var?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalo: {
|
||||
...next.channels?.zalo,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
} else {
|
||||
token = String(
|
||||
await prompter.text({
|
||||
message: "Enter Zalo bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (hasConfigToken) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Zalo token already configured. Keep it?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
token = String(
|
||||
await prompter.text({
|
||||
message: "Enter Zalo bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else {
|
||||
token = String(
|
||||
await prompter.text({
|
||||
message: "Enter Zalo bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (token) {
|
||||
if (zaloAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalo: {
|
||||
...next.channels?.zalo,
|
||||
enabled: true,
|
||||
botToken: token,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalo: {
|
||||
...next.channels?.zalo,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(next.channels?.zalo?.accounts ?? {}),
|
||||
[zaloAccountId]: {
|
||||
...(next.channels?.zalo?.accounts?.[zaloAccountId] ?? {}),
|
||||
enabled: true,
|
||||
botToken: token,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
}
|
||||
|
||||
const wantsWebhook = await prompter.confirm({
|
||||
message: "Use webhook mode for Zalo?",
|
||||
initialValue: false,
|
||||
});
|
||||
if (wantsWebhook) {
|
||||
const webhookUrl = String(
|
||||
await prompter.text({
|
||||
message: "Webhook URL (https://...) ",
|
||||
validate: (value) => (value?.trim()?.startsWith("https://") ? undefined : "HTTPS URL required"),
|
||||
}),
|
||||
).trim();
|
||||
const defaultPath = (() => {
|
||||
try {
|
||||
return new URL(webhookUrl).pathname || "/zalo-webhook";
|
||||
} catch {
|
||||
return "/zalo-webhook";
|
||||
}
|
||||
})();
|
||||
const webhookSecret = String(
|
||||
await prompter.text({
|
||||
message: "Webhook secret (8-256 chars)",
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "");
|
||||
if (raw.length < 8 || raw.length > 256) return "8-256 chars";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
).trim();
|
||||
const webhookPath = String(
|
||||
await prompter.text({
|
||||
message: "Webhook path (optional)",
|
||||
initialValue: defaultPath,
|
||||
}),
|
||||
).trim();
|
||||
next = setZaloUpdateMode(
|
||||
next,
|
||||
zaloAccountId,
|
||||
"webhook",
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
webhookPath || undefined,
|
||||
);
|
||||
} else {
|
||||
next = setZaloUpdateMode(next, zaloAccountId, "polling");
|
||||
}
|
||||
|
||||
if (forceAllowFrom) {
|
||||
next = await promptZaloAllowFrom({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId: zaloAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
return { cfg: next, accountId: zaloAccountId };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getMe, ZaloApiError, type ZaloBotInfo, type ZaloFetch } from "./api.js";
|
||||
|
||||
export type ZaloProbeResult = {
|
||||
ok: boolean;
|
||||
bot?: ZaloBotInfo;
|
||||
error?: string;
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
export async function probeZalo(
|
||||
token: string,
|
||||
timeoutMs = 5000,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloProbeResult> {
|
||||
if (!token?.trim()) {
|
||||
return { ok: false, error: "No token provided", elapsedMs: 0 };
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await getMe(token.trim(), timeoutMs, fetcher);
|
||||
const elapsedMs = Date.now() - startTime;
|
||||
|
||||
if (response.ok && response.result) {
|
||||
return { ok: true, bot: response.result, elapsedMs };
|
||||
}
|
||||
|
||||
return { ok: false, error: "Invalid response from Zalo API", elapsedMs };
|
||||
} catch (err) {
|
||||
const elapsedMs = Date.now() - startTime;
|
||||
|
||||
if (err instanceof ZaloApiError) {
|
||||
return { ok: false, error: err.description ?? err.message, elapsedMs };
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (err.name === "AbortError") {
|
||||
return { ok: false, error: `Request timed out after ${timeoutMs}ms`, elapsedMs };
|
||||
}
|
||||
return { ok: false, error: err.message, elapsedMs };
|
||||
}
|
||||
|
||||
return { ok: false, error: String(err), elapsedMs };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ProxyAgent, fetch as undiciFetch } from "undici";
|
||||
import type { Dispatcher } from "undici";
|
||||
|
||||
import type { ZaloFetch } from "./api.js";
|
||||
|
||||
const proxyCache = new Map<string, ZaloFetch>();
|
||||
|
||||
export function resolveZaloProxyFetch(proxyUrl?: string | null): ZaloFetch | undefined {
|
||||
const trimmed = proxyUrl?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const cached = proxyCache.get(trimmed);
|
||||
if (cached) return cached;
|
||||
const agent = new ProxyAgent(trimmed);
|
||||
const fetcher: ZaloFetch = (input, init) =>
|
||||
undiciFetch(input, { ...(init ?? {}), dispatcher: agent as Dispatcher });
|
||||
proxyCache.set(trimmed, fetcher);
|
||||
return fetcher;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import type { ZaloFetch } from "./api.js";
|
||||
import { sendMessage, sendPhoto } from "./api.js";
|
||||
import { resolveZaloAccount } from "./accounts.js";
|
||||
import { resolveZaloProxyFetch } from "./proxy.js";
|
||||
import { resolveZaloToken } from "./token.js";
|
||||
|
||||
export type ZaloSendOptions = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
cfg?: CoreConfig;
|
||||
mediaUrl?: string;
|
||||
caption?: string;
|
||||
verbose?: boolean;
|
||||
proxy?: string;
|
||||
};
|
||||
|
||||
export type ZaloSendResult = {
|
||||
ok: boolean;
|
||||
messageId?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function resolveSendContext(options: ZaloSendOptions): {
|
||||
token: string;
|
||||
fetcher?: ZaloFetch;
|
||||
} {
|
||||
if (options.cfg) {
|
||||
const account = resolveZaloAccount({
|
||||
cfg: options.cfg,
|
||||
accountId: options.accountId,
|
||||
});
|
||||
const token = options.token || account.token;
|
||||
const proxy = options.proxy ?? account.config.proxy;
|
||||
return { token, fetcher: resolveZaloProxyFetch(proxy) };
|
||||
}
|
||||
|
||||
const token = options.token ?? resolveZaloToken(undefined, options.accountId).token;
|
||||
const proxy = options.proxy;
|
||||
return { token: token || process.env.ZALO_BOT_TOKEN?.trim() || "", fetcher: resolveZaloProxyFetch(proxy) };
|
||||
}
|
||||
|
||||
export async function sendMessageZalo(
|
||||
chatId: string,
|
||||
text: string,
|
||||
options: ZaloSendOptions = {},
|
||||
): Promise<ZaloSendResult> {
|
||||
const { token, fetcher } = resolveSendContext(options);
|
||||
|
||||
if (!token) {
|
||||
return { ok: false, error: "No Zalo bot token configured" };
|
||||
}
|
||||
|
||||
if (!chatId?.trim()) {
|
||||
return { ok: false, error: "No chat_id provided" };
|
||||
}
|
||||
|
||||
if (options.mediaUrl) {
|
||||
return sendPhotoZalo(chatId, options.mediaUrl, {
|
||||
...options,
|
||||
token,
|
||||
caption: text || options.caption,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendMessage(token, {
|
||||
chat_id: chatId.trim(),
|
||||
text: text.slice(0, 2000),
|
||||
}, fetcher);
|
||||
|
||||
if (response.ok && response.result) {
|
||||
return { ok: true, messageId: response.result.message_id };
|
||||
}
|
||||
|
||||
return { ok: false, error: "Failed to send message" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPhotoZalo(
|
||||
chatId: string,
|
||||
photoUrl: string,
|
||||
options: ZaloSendOptions = {},
|
||||
): Promise<ZaloSendResult> {
|
||||
const { token, fetcher } = resolveSendContext(options);
|
||||
|
||||
if (!token) {
|
||||
return { ok: false, error: "No Zalo bot token configured" };
|
||||
}
|
||||
|
||||
if (!chatId?.trim()) {
|
||||
return { ok: false, error: "No chat_id provided" };
|
||||
}
|
||||
|
||||
if (!photoUrl?.trim()) {
|
||||
return { ok: false, error: "No photo URL provided" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendPhoto(token, {
|
||||
chat_id: chatId.trim(),
|
||||
photo: photoUrl.trim(),
|
||||
caption: options.caption?.slice(0, 2000),
|
||||
}, fetcher);
|
||||
|
||||
if (response.ok && response.result) {
|
||||
return { ok: true, messageId: response.result.message_id };
|
||||
}
|
||||
|
||||
return { ok: false, error: "Failed to send photo" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
export function normalizeAccountId(value: string | undefined | null): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) return DEFAULT_ACCOUNT_ID;
|
||||
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
|
||||
return (
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
.slice(0, 64) || DEFAULT_ACCOUNT_ID
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { DEFAULT_ACCOUNT_ID } from "./account-ids.js";
|
||||
|
||||
type ChannelSection = {
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type ConfigWithChannels = {
|
||||
channels?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function setAccountEnabledInConfigSection<T extends ConfigWithChannels>(params: {
|
||||
cfg: T;
|
||||
sectionKey: string;
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
allowTopLevel?: boolean;
|
||||
}): T {
|
||||
const accountKey = params.accountId || DEFAULT_ACCOUNT_ID;
|
||||
const channels = params.cfg.channels;
|
||||
const base = (channels?.[params.sectionKey] as ChannelSection | undefined) ?? undefined;
|
||||
const hasAccounts = Boolean(base?.accounts);
|
||||
if (params.allowTopLevel && accountKey === DEFAULT_ACCOUNT_ID && !hasAccounts) {
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...channels,
|
||||
[params.sectionKey]: {
|
||||
...base,
|
||||
enabled: params.enabled,
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
|
||||
const baseAccounts = (base?.accounts ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const existing = baseAccounts[accountKey] ?? {};
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...channels,
|
||||
[params.sectionKey]: {
|
||||
...base,
|
||||
accounts: {
|
||||
...baseAccounts,
|
||||
[accountKey]: {
|
||||
...existing,
|
||||
enabled: params.enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
|
||||
export function deleteAccountFromConfigSection<T extends ConfigWithChannels>(params: {
|
||||
cfg: T;
|
||||
sectionKey: string;
|
||||
accountId: string;
|
||||
clearBaseFields?: string[];
|
||||
}): T {
|
||||
const accountKey = params.accountId || DEFAULT_ACCOUNT_ID;
|
||||
const channels = params.cfg.channels as Record<string, unknown> | undefined;
|
||||
const base = (channels?.[params.sectionKey] as ChannelSection | undefined) ?? undefined;
|
||||
if (!base) return params.cfg;
|
||||
|
||||
const baseAccounts =
|
||||
base.accounts && typeof base.accounts === "object" ? { ...base.accounts } : undefined;
|
||||
|
||||
if (accountKey !== DEFAULT_ACCOUNT_ID) {
|
||||
const accounts = baseAccounts ? { ...baseAccounts } : {};
|
||||
delete accounts[accountKey];
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...channels,
|
||||
[params.sectionKey]: {
|
||||
...base,
|
||||
accounts: Object.keys(accounts).length ? accounts : undefined,
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
|
||||
if (baseAccounts && Object.keys(baseAccounts).length > 0) {
|
||||
delete baseAccounts[accountKey];
|
||||
const baseRecord = { ...(base as Record<string, unknown>) };
|
||||
for (const field of params.clearBaseFields ?? []) {
|
||||
if (field in baseRecord) baseRecord[field] = undefined;
|
||||
}
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...channels,
|
||||
[params.sectionKey]: {
|
||||
...baseRecord,
|
||||
accounts: Object.keys(baseAccounts).length ? baseAccounts : undefined,
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
|
||||
const nextChannels = { ...channels } as Record<string, unknown>;
|
||||
delete nextChannels[params.sectionKey];
|
||||
const nextCfg = { ...params.cfg } as T;
|
||||
if (Object.keys(nextChannels).length > 0) {
|
||||
nextCfg.channels = nextChannels as T["channels"];
|
||||
} else {
|
||||
delete nextCfg.channels;
|
||||
}
|
||||
return nextCfg;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./account-ids.js";
|
||||
|
||||
type ConfigWithChannels = {
|
||||
channels?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ChannelSectionBase = {
|
||||
name?: string;
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
function channelHasAccounts(cfg: ConfigWithChannels, channelKey: string): boolean {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
const base = channels?.[channelKey] as ChannelSectionBase | undefined;
|
||||
return Boolean(base?.accounts && Object.keys(base.accounts).length > 0);
|
||||
}
|
||||
|
||||
function shouldStoreNameInAccounts(params: {
|
||||
cfg: ConfigWithChannels;
|
||||
channelKey: string;
|
||||
accountId: string;
|
||||
alwaysUseAccounts?: boolean;
|
||||
}): boolean {
|
||||
if (params.alwaysUseAccounts) return true;
|
||||
if (params.accountId !== DEFAULT_ACCOUNT_ID) return true;
|
||||
return channelHasAccounts(params.cfg, params.channelKey);
|
||||
}
|
||||
|
||||
export function applyAccountNameToChannelSection<T extends ConfigWithChannels>(params: {
|
||||
cfg: T;
|
||||
channelKey: string;
|
||||
accountId: string;
|
||||
name?: string;
|
||||
alwaysUseAccounts?: boolean;
|
||||
}): T {
|
||||
const trimmed = params.name?.trim();
|
||||
if (!trimmed) return params.cfg;
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const channels = params.cfg.channels as Record<string, unknown> | undefined;
|
||||
const baseConfig = channels?.[params.channelKey];
|
||||
const base =
|
||||
typeof baseConfig === "object" && baseConfig ? (baseConfig as ChannelSectionBase) : undefined;
|
||||
const useAccounts = shouldStoreNameInAccounts({
|
||||
cfg: params.cfg,
|
||||
channelKey: params.channelKey,
|
||||
accountId,
|
||||
alwaysUseAccounts: params.alwaysUseAccounts,
|
||||
});
|
||||
if (!useAccounts && accountId === DEFAULT_ACCOUNT_ID) {
|
||||
const safeBase = base ?? {};
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...channels,
|
||||
[params.channelKey]: {
|
||||
...safeBase,
|
||||
name: trimmed,
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
const baseAccounts: Record<string, Record<string, unknown>> = base?.accounts ?? {};
|
||||
const existingAccount = baseAccounts[accountId] ?? {};
|
||||
const baseWithoutName =
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? (({ name: _ignored, ...rest }) => rest)(base ?? {})
|
||||
: (base ?? {});
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...channels,
|
||||
[params.channelKey]: {
|
||||
...baseWithoutName,
|
||||
accounts: {
|
||||
...baseAccounts,
|
||||
[accountId]: {
|
||||
...existingAccount,
|
||||
name: trimmed,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
|
||||
export function migrateBaseNameToDefaultAccount<T extends ConfigWithChannels>(params: {
|
||||
cfg: T;
|
||||
channelKey: string;
|
||||
alwaysUseAccounts?: boolean;
|
||||
}): T {
|
||||
if (params.alwaysUseAccounts) return params.cfg;
|
||||
const channels = params.cfg.channels as Record<string, unknown> | undefined;
|
||||
const base = channels?.[params.channelKey] as ChannelSectionBase | undefined;
|
||||
const baseName = base?.name?.trim();
|
||||
if (!baseName) return params.cfg;
|
||||
const accounts: Record<string, Record<string, unknown>> = {
|
||||
...base?.accounts,
|
||||
};
|
||||
const defaultAccount = accounts[DEFAULT_ACCOUNT_ID] ?? {};
|
||||
if (!defaultAccount.name) {
|
||||
accounts[DEFAULT_ACCOUNT_ID] = { ...defaultAccount, name: baseName };
|
||||
}
|
||||
const { name: _ignored, ...rest } = base ?? {};
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...channels,
|
||||
[params.channelKey]: {
|
||||
...rest,
|
||||
accounts,
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./account-ids.js";
|
||||
|
||||
export type PromptAccountIdParams<TConfig> = {
|
||||
cfg: TConfig;
|
||||
prompter: WizardPrompter;
|
||||
label: string;
|
||||
currentId?: string;
|
||||
listAccountIds: (cfg: TConfig) => string[];
|
||||
defaultAccountId: string;
|
||||
};
|
||||
|
||||
export async function promptAccountId<TConfig>(
|
||||
params: PromptAccountIdParams<TConfig>,
|
||||
): Promise<string> {
|
||||
const existingIds = params.listAccountIds(params.cfg);
|
||||
const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
|
||||
const choice = (await params.prompter.select({
|
||||
message: `${params.label} account`,
|
||||
options: [
|
||||
...existingIds.map((id) => ({
|
||||
value: id,
|
||||
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
|
||||
})),
|
||||
{ value: "__new__", label: "Add a new account" },
|
||||
],
|
||||
initialValue: initial,
|
||||
})) as string;
|
||||
|
||||
if (choice !== "__new__") return normalizeAccountId(choice);
|
||||
|
||||
const entered = await params.prompter.text({
|
||||
message: `New ${params.label} account id`,
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
const normalized = normalizeAccountId(String(entered));
|
||||
if (String(entered).trim() !== normalized) {
|
||||
await params.prompter.note(
|
||||
`Normalized account id to "${normalized}".`,
|
||||
`${params.label} account`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function addWildcardAllowFrom(
|
||||
allowFrom?: Array<string | number> | null,
|
||||
): Array<string | number> {
|
||||
const next = (allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean);
|
||||
if (!next.includes("*")) next.push("*");
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const PAIRING_APPROVED_MESSAGE =
|
||||
"\u2705 Clawdbot access approved. Send a message to start chatting.";
|
||||
|
||||
export function formatPairingApproveHint(channelId: string): string {
|
||||
return `Approve via: clawdbot pairing list ${channelId} / clawdbot pairing approve ${channelId} <code>`;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../../src/channels/plugins/types.js";
|
||||
|
||||
type ZaloAccountStatus = {
|
||||
accountId?: unknown;
|
||||
enabled?: unknown;
|
||||
configured?: unknown;
|
||||
dmPolicy?: unknown;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object");
|
||||
|
||||
const asString = (value: unknown): string | undefined =>
|
||||
typeof value === "string" ? value : typeof value === "number" ? String(value) : undefined;
|
||||
|
||||
function readZaloAccountStatus(value: ChannelAccountSnapshot): ZaloAccountStatus | null {
|
||||
if (!isRecord(value)) return null;
|
||||
return {
|
||||
accountId: value.accountId,
|
||||
enabled: value.enabled,
|
||||
configured: value.configured,
|
||||
dmPolicy: value.dmPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectZaloStatusIssues(
|
||||
accounts: ChannelAccountSnapshot[],
|
||||
): ChannelStatusIssue[] {
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
for (const entry of accounts) {
|
||||
const account = readZaloAccountStatus(entry);
|
||||
if (!account) continue;
|
||||
const accountId = asString(account.accountId) ?? "default";
|
||||
const enabled = account.enabled !== false;
|
||||
const configured = account.configured === true;
|
||||
if (!enabled || !configured) continue;
|
||||
|
||||
if (account.dmPolicy === "open") {
|
||||
issues.push({
|
||||
channel: "zalo",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message:
|
||||
'Zalo dmPolicy is "open", allowing any user to message the bot without pairing.',
|
||||
fix: 'Set channels.zalo.dmPolicy to "pairing" or "allowlist" to restrict access.',
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import type { ZaloConfig } from "./types.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "./shared/account-ids.js";
|
||||
|
||||
export type ZaloTokenResolution = {
|
||||
token: string;
|
||||
source: "env" | "config" | "configFile" | "none";
|
||||
};
|
||||
|
||||
export function resolveZaloToken(
|
||||
config: ZaloConfig | undefined,
|
||||
accountId?: string | null,
|
||||
): ZaloTokenResolution {
|
||||
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const isDefaultAccount = resolvedAccountId === DEFAULT_ACCOUNT_ID;
|
||||
const baseConfig = config;
|
||||
const accountConfig =
|
||||
resolvedAccountId !== DEFAULT_ACCOUNT_ID
|
||||
? (baseConfig?.accounts?.[resolvedAccountId] as ZaloConfig | undefined)
|
||||
: undefined;
|
||||
|
||||
if (accountConfig) {
|
||||
const token = accountConfig.botToken?.trim();
|
||||
if (token) return { token, source: "config" };
|
||||
const tokenFile = accountConfig.tokenFile?.trim();
|
||||
if (tokenFile) {
|
||||
try {
|
||||
const fileToken = readFileSync(tokenFile, "utf8").trim();
|
||||
if (fileToken) return { token: fileToken, source: "configFile" };
|
||||
} catch {
|
||||
// ignore read failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDefaultAccount) {
|
||||
const token = baseConfig?.botToken?.trim();
|
||||
if (token) return { token, source: "config" };
|
||||
const tokenFile = baseConfig?.tokenFile?.trim();
|
||||
if (tokenFile) {
|
||||
try {
|
||||
const fileToken = readFileSync(tokenFile, "utf8").trim();
|
||||
if (fileToken) return { token: fileToken, source: "configFile" };
|
||||
} catch {
|
||||
// ignore read failures
|
||||
}
|
||||
}
|
||||
const envToken = process.env.ZALO_BOT_TOKEN?.trim();
|
||||
if (envToken) return { token: envToken, source: "env" };
|
||||
}
|
||||
|
||||
return { token: "", source: "none" };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export function readStringParam(
|
||||
params: Record<string, unknown>,
|
||||
key: string,
|
||||
opts?: { required?: boolean; allowEmpty?: boolean; trim?: boolean },
|
||||
): string | undefined {
|
||||
const raw = params[key];
|
||||
if (raw === undefined || raw === null) {
|
||||
if (opts?.required) throw new Error(`${key} is required`);
|
||||
return undefined;
|
||||
}
|
||||
const value = String(raw);
|
||||
const trimmed = opts?.trim === false ? value : value.trim();
|
||||
if (!opts?.allowEmpty && !trimmed) {
|
||||
if (opts?.required) throw new Error(`${key} is required`);
|
||||
return undefined;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function jsonResult(payload: unknown) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(payload, null, 2),
|
||||
},
|
||||
],
|
||||
details: payload,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export type ZaloAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** If false, do not start this Zalo account. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Bot token from Zalo Bot Creator. */
|
||||
botToken?: string;
|
||||
/** Path to file containing the bot token. */
|
||||
tokenFile?: string;
|
||||
/** Webhook URL for receiving updates (HTTPS required). */
|
||||
webhookUrl?: string;
|
||||
/** Webhook secret token (8-256 chars) for request verification. */
|
||||
webhookSecret?: string;
|
||||
/** Webhook path for the gateway HTTP server (defaults to webhook URL path). */
|
||||
webhookPath?: string;
|
||||
/** Direct message access policy (default: pairing). */
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
/** Allowlist for DM senders (Zalo user IDs). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Max inbound media size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
/** Proxy URL for API requests. */
|
||||
proxy?: string;
|
||||
};
|
||||
|
||||
export type ZaloConfig = {
|
||||
/** Optional per-account Zalo configuration (multi-account). */
|
||||
accounts?: Record<string, ZaloAccountConfig>;
|
||||
/** Default account ID when multiple accounts are configured. */
|
||||
defaultAccount?: string;
|
||||
} & ZaloAccountConfig;
|
||||
|
||||
export type ZaloTokenSource = "env" | "config" | "configFile" | "none";
|
||||
|
||||
export type ResolvedZaloAccount = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
tokenSource: ZaloTokenSource;
|
||||
config: ZaloAccountConfig;
|
||||
};
|
||||
|
||||
export type CoreConfig = {
|
||||
channels?: {
|
||||
zalo?: ZaloConfig;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
+9
-4
@@ -40,6 +40,8 @@
|
||||
"dist/*.js",
|
||||
"dist/*.json",
|
||||
"docs/**",
|
||||
"extensions/**",
|
||||
"assets/**",
|
||||
"skills/**",
|
||||
"README.md",
|
||||
"README-header.png",
|
||||
@@ -135,10 +137,10 @@
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
"@homebridge/ciao": "^1.3.4",
|
||||
"@mariozechner/pi-agent-core": "^0.45.3",
|
||||
"@mariozechner/pi-agent-core": "0.45.7",
|
||||
"@mariozechner/pi-ai": "0.45.7",
|
||||
"@mariozechner/pi-coding-agent": "^0.45.3",
|
||||
"@mariozechner/pi-tui": "^0.45.3",
|
||||
"@mariozechner/pi-coding-agent": "^0.45.7",
|
||||
"@mariozechner/pi-tui": "^0.45.7",
|
||||
"@microsoft/agents-hosting": "^1.1.1",
|
||||
"@microsoft/agents-hosting-express": "^1.1.1",
|
||||
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
|
||||
@@ -159,6 +161,7 @@
|
||||
"express": "^5.2.1",
|
||||
"file-type": "^21.3.0",
|
||||
"grammy": "^1.39.2",
|
||||
"hono": "4.11.4",
|
||||
"jiti": "^2.6.1",
|
||||
"json5": "^2.2.3",
|
||||
"long": "5.3.2",
|
||||
@@ -207,9 +210,11 @@
|
||||
"pnpm": {
|
||||
"minimumReleaseAge": 2880,
|
||||
"overrides": {
|
||||
"@sinclair/typebox": "0.34.47"
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"hono": "4.11.4"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@mariozechner/pi-agent-core@0.45.7": "patches/@mariozechner__pi-agent-core.patch",
|
||||
"@mariozechner/pi-ai@0.45.7": "patches/@mariozechner__pi-ai@0.45.7.patch"
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+41
-29
@@ -6,8 +6,12 @@ settings:
|
||||
|
||||
overrides:
|
||||
'@sinclair/typebox': 0.34.47
|
||||
hono: 4.11.4
|
||||
|
||||
patchedDependencies:
|
||||
'@mariozechner/pi-agent-core@0.45.7':
|
||||
hash: 01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4
|
||||
path: patches/@mariozechner__pi-agent-core.patch
|
||||
'@mariozechner/pi-ai@0.45.7':
|
||||
hash: e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9
|
||||
path: patches/@mariozechner__pi-ai@0.45.7.patch
|
||||
@@ -18,7 +22,7 @@ importers:
|
||||
dependencies:
|
||||
'@buape/carbon':
|
||||
specifier: 0.0.0-beta-20260110172854
|
||||
version: 0.0.0-beta-20260110172854(hono@4.11.3)
|
||||
version: 0.0.0-beta-20260110172854(hono@4.11.4)
|
||||
'@clack/prompts':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
@@ -32,17 +36,17 @@ importers:
|
||||
specifier: ^1.3.4
|
||||
version: 1.3.4
|
||||
'@mariozechner/pi-agent-core':
|
||||
specifier: ^0.45.3
|
||||
version: 0.45.3(ws@8.19.0)(zod@4.3.5)
|
||||
specifier: 0.45.7
|
||||
version: 0.45.7(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: 0.45.7
|
||||
version: 0.45.7(patch_hash=e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: ^0.45.3
|
||||
version: 0.45.3(ws@8.19.0)(zod@4.3.5)
|
||||
specifier: ^0.45.7
|
||||
version: 0.45.7(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui':
|
||||
specifier: ^0.45.3
|
||||
version: 0.45.3
|
||||
specifier: ^0.45.7
|
||||
version: 0.45.7
|
||||
'@microsoft/agents-hosting':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
@@ -103,6 +107,9 @@ importers:
|
||||
grammy:
|
||||
specifier: ^1.39.2
|
||||
version: 1.39.2
|
||||
hono:
|
||||
specifier: 4.11.4
|
||||
version: 4.11.4
|
||||
jiti:
|
||||
specifier: ^2.6.1
|
||||
version: 2.6.1
|
||||
@@ -690,7 +697,7 @@ packages:
|
||||
resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
hono: 4.11.4
|
||||
|
||||
'@huggingface/jinja@0.5.3':
|
||||
resolution: {integrity: sha512-asqfZ4GQS0hD876Uw4qiUb7Tr/V5Q+JZuo2L+BtdrD4U40QU58nIRq3ZSgAzJgT874VLjhGVacaYfrdpXtEvtA==}
|
||||
@@ -952,8 +959,8 @@ packages:
|
||||
peerDependencies:
|
||||
lit: ^3.3.1
|
||||
|
||||
'@mariozechner/pi-agent-core@0.45.3':
|
||||
resolution: {integrity: sha512-yWgIKuhhCOxLdbNUcX5oNELdUSqkvnoYsXWOq6/IDiolvQMag4fWNsabeAd2OqiueH8k9sFx3G5cFR53PtHLEw==}
|
||||
'@mariozechner/pi-agent-core@0.45.7':
|
||||
resolution: {integrity: sha512-AwPwojEyJFBpmUIpgSfXinRh+hbd8TW0uZX2qNpRybyzbP0PnMp0tBthaG0Ao/wAIws4KLZQQEToQiDADpEUhg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@mariozechner/pi-ai@0.45.7':
|
||||
@@ -961,13 +968,13 @@ packages:
|
||||
engines: {node: '>=20.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.45.3':
|
||||
resolution: {integrity: sha512-Rt1SLmZ5Z2Ch3s/PjIJXP3VNCjbjm43T8q2cujMO/zRCGjgElcfO66ls2MoLoAxCGyfy/9FA1OrCoCrGsWkWcw==}
|
||||
'@mariozechner/pi-coding-agent@0.45.7':
|
||||
resolution: {integrity: sha512-+2TtZXIF6Iy4WxwpyeK9MXyYdfsuvIcRimotRDzhT7nM6TMLxJkDzHovqrcQF9/fAFd9wNHmRBA52PQIBicTSw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-tui@0.45.3':
|
||||
resolution: {integrity: sha512-Z/ThVu1JHLQYDR1TgZdCXlaR9rimZ5wuYUO+dy773Cz0xFOg+E6YSfPDRu7t3TErLt59v/gnBw72cIdfIXoYpw==}
|
||||
'@mariozechner/pi-tui@0.45.7':
|
||||
resolution: {integrity: sha512-c4pMvyuC28PfpAYCsO01E651r+SAiNg216plmc0ej5qnizQm2x7X14C3UfxMfmWAMJ+U9Y0O7PucPdmXeHbjnw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@microsoft/agents-activity@1.1.1':
|
||||
@@ -2703,8 +2710,8 @@ packages:
|
||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
hono@4.11.3:
|
||||
resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==}
|
||||
hono@4.11.4:
|
||||
resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
hookified@1.15.0:
|
||||
@@ -4045,6 +4052,10 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
wasm-vips@0.0.16:
|
||||
resolution: {integrity: sha512-4/bEq8noAFt7DX3VT+Vt5AgNtnnOLwvmrDbduWfiv9AV+VYkbUU4f9Dam9e6khRqPinyClFHCqiwATTTJEiGwA==}
|
||||
engines: {node: '>=16.4.0'}
|
||||
|
||||
web-streams-polyfill@3.3.3:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -4639,14 +4650,14 @@ snapshots:
|
||||
|
||||
'@borewit/text-codec@0.2.1': {}
|
||||
|
||||
'@buape/carbon@0.0.0-beta-20260110172854(hono@4.11.3)':
|
||||
'@buape/carbon@0.0.0-beta-20260110172854(hono@4.11.4)':
|
||||
dependencies:
|
||||
'@types/node': 24.10.7
|
||||
discord-api-types: 0.38.36
|
||||
optionalDependencies:
|
||||
'@cloudflare/workers-types': 4.20251205.0
|
||||
'@discordjs/voice': 0.19.0
|
||||
'@hono/node-server': 1.19.6(hono@4.11.3)
|
||||
'@hono/node-server': 1.19.6(hono@4.11.4)
|
||||
'@types/bun': 1.3.3
|
||||
'@types/ws': 8.18.1
|
||||
ws: 8.18.3
|
||||
@@ -4842,9 +4853,9 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@hono/node-server@1.19.6(hono@4.11.3)':
|
||||
'@hono/node-server@1.19.6(hono@4.11.4)':
|
||||
dependencies:
|
||||
hono: 4.11.3
|
||||
hono: 4.11.4
|
||||
optional: true
|
||||
|
||||
'@huggingface/jinja@0.5.3': {}
|
||||
@@ -5065,10 +5076,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- tailwindcss
|
||||
|
||||
'@mariozechner/pi-agent-core@0.45.3(ws@8.19.0)(zod@4.3.5)':
|
||||
'@mariozechner/pi-agent-core@0.45.7(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.45.7(patch_hash=e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui': 0.45.3
|
||||
'@mariozechner/pi-tui': 0.45.7
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
@@ -5100,13 +5111,13 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.45.3(ws@8.19.0)(zod@4.3.5)':
|
||||
'@mariozechner/pi-coding-agent@0.45.7(ws@8.19.0)(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@mariozechner/clipboard': 0.3.0
|
||||
'@mariozechner/jiti': 2.6.2
|
||||
'@mariozechner/pi-agent-core': 0.45.3(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-agent-core': 0.45.7(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-ai': 0.45.7(patch_hash=e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui': 0.45.3
|
||||
'@mariozechner/pi-tui': 0.45.7
|
||||
chalk: 5.6.2
|
||||
cli-highlight: 2.1.11
|
||||
diff: 8.0.3
|
||||
@@ -5115,7 +5126,7 @@ snapshots:
|
||||
marked: 15.0.12
|
||||
minimatch: 10.1.1
|
||||
proper-lockfile: 4.1.2
|
||||
sharp: 0.34.5
|
||||
wasm-vips: 0.0.16
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
@@ -5125,7 +5136,7 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-tui@0.45.3':
|
||||
'@mariozechner/pi-tui@0.45.7':
|
||||
dependencies:
|
||||
'@types/mime-types': 2.1.4
|
||||
chalk: 5.6.2
|
||||
@@ -7015,8 +7026,7 @@ snapshots:
|
||||
|
||||
highlight.js@11.11.1: {}
|
||||
|
||||
hono@4.11.3:
|
||||
optional: true
|
||||
hono@4.11.4: {}
|
||||
|
||||
hookified@1.15.0: {}
|
||||
|
||||
@@ -8432,6 +8442,8 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
wasm-vips@0.0.16: {}
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
+1
-1
@@ -10,4 +10,4 @@ onlyBuiltDependencies:
|
||||
- sharp
|
||||
|
||||
patchedDependencies:
|
||||
'@mariozechner/pi-ai@0.45.3': patches/@mariozechner__pi-ai@0.45.3.patch
|
||||
'@mariozechner/pi-ai@0.45.7': patches/@mariozechner__pi-ai@0.45.7.patch
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
name: brave-search
|
||||
description: Web search and content extraction via Brave Search API.
|
||||
homepage: https://brave.com/search/api
|
||||
metadata: {"clawdbot":{"emoji":"🦁","requires":{"bins":["node"],"env":["BRAVE_API_KEY"]},"primaryEnv":"BRAVE_API_KEY"}}
|
||||
---
|
||||
|
||||
# Brave Search
|
||||
|
||||
Headless web search (and lightweight content extraction) using Brave Search API. No browser required.
|
||||
|
||||
## Search
|
||||
|
||||
```bash
|
||||
node {baseDir}/scripts/search.mjs "query"
|
||||
node {baseDir}/scripts/search.mjs "query" -n 10
|
||||
node {baseDir}/scripts/search.mjs "query" --content
|
||||
node {baseDir}/scripts/search.mjs "query" -n 3 --content
|
||||
```
|
||||
|
||||
## Extract a page
|
||||
|
||||
```bash
|
||||
node {baseDir}/scripts/content.mjs "https://example.com/article"
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Needs `BRAVE_API_KEY`.
|
||||
- Content extraction is best-effort (good for articles; not for app-like sites).
|
||||
- If a site is blocked or too JS-heavy, prefer the `summarize` skill (it can use a Firecrawl fallback).
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
function usage() {
|
||||
console.error(`Usage: content.mjs <url>`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
export async function fetchAsMarkdown(url) {
|
||||
const resp = await fetch(url, {
|
||||
headers: { "User-Agent": "clawdbot-brave-search/1.0" },
|
||||
});
|
||||
const html = await resp.text();
|
||||
|
||||
// Very lightweight “readability-ish” extraction without dependencies:
|
||||
// - drop script/style/nav/footer
|
||||
// - strip tags
|
||||
// - keep paragraphs
|
||||
const cleaned = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<(nav|footer|header)[\s\S]*?<\/\1>/gi, " ")
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n\n")
|
||||
.replace(/<\/div>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.trim();
|
||||
|
||||
if (!resp.ok) {
|
||||
return `> Fetch failed (${resp.status}).\n\n${cleaned.slice(0, 2000)}\n`;
|
||||
}
|
||||
|
||||
const paras = cleaned
|
||||
.split("\n\n")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 30);
|
||||
|
||||
return paras.map((p) => `- ${p}`).join("\n") + "\n";
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0 || args[0] === "-h" || args[0] === "--help") usage();
|
||||
const url = args[0];
|
||||
process.stdout.write(await fetchAsMarkdown(url));
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
function usage() {
|
||||
console.error(`Usage: search.mjs "query" [-n 5] [--content]`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0 || args[0] === "-h" || args[0] === "--help") usage();
|
||||
|
||||
const query = args[0];
|
||||
let n = 5;
|
||||
let withContent = false;
|
||||
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === "-n") {
|
||||
n = Number.parseInt(args[i + 1] ?? "5", 10);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (a === "--content") {
|
||||
withContent = true;
|
||||
continue;
|
||||
}
|
||||
console.error(`Unknown arg: ${a}`);
|
||||
usage();
|
||||
}
|
||||
|
||||
const apiKey = (process.env.BRAVE_API_KEY ?? "").trim();
|
||||
if (!apiKey) {
|
||||
console.error("Missing BRAVE_API_KEY");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const endpoint = new URL("https://api.search.brave.com/res/v1/web/search");
|
||||
endpoint.searchParams.set("q", query);
|
||||
endpoint.searchParams.set("count", String(Math.max(1, Math.min(n, 20))));
|
||||
endpoint.searchParams.set("text_decorations", "false");
|
||||
endpoint.searchParams.set("safesearch", "moderate");
|
||||
|
||||
const resp = await fetch(endpoint, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-Subscription-Token": apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
throw new Error(`Brave Search failed (${resp.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
const results = (data?.web?.results ?? []).slice(0, n);
|
||||
|
||||
const lines = [];
|
||||
for (const r of results) {
|
||||
const title = String(r?.title ?? "").trim();
|
||||
const url = String(r?.url ?? "").trim();
|
||||
const desc = String(r?.description ?? "").trim();
|
||||
if (!title || !url) continue;
|
||||
lines.push(`- ${title}\n ${url}${desc ? `\n ${desc}` : ""}`);
|
||||
}
|
||||
|
||||
process.stdout.write(lines.join("\n\n") + "\n");
|
||||
|
||||
if (!withContent) process.exit(0);
|
||||
|
||||
process.stdout.write("\n---\n\n");
|
||||
for (const r of results) {
|
||||
const title = String(r?.title ?? "").trim();
|
||||
const url = String(r?.url ?? "").trim();
|
||||
if (!url) continue;
|
||||
process.stdout.write(`# ${title || url}\n${url}\n\n`);
|
||||
const child = await import("./content.mjs");
|
||||
const text = await child.fetchAsMarkdown(url);
|
||||
process.stdout.write(text.trimEnd() + "\n\n");
|
||||
}
|
||||
@@ -6,7 +6,12 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
callGatewayTool: vi.fn(async () => ({ ok: true })),
|
||||
callGatewayTool: vi.fn(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return { hash: "hash-1" };
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("gateway tool", () => {
|
||||
@@ -71,11 +76,13 @@ describe("gateway tool", () => {
|
||||
raw,
|
||||
});
|
||||
|
||||
expect(callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {});
|
||||
expect(callGatewayTool).toHaveBeenCalledWith(
|
||||
"config.apply",
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
raw: raw.trim(),
|
||||
baseHash: "hash-1",
|
||||
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
|
||||
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
|
||||
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
|
||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
||||
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
|
||||
|
||||
export function createClawdbotTools(options?: {
|
||||
browserControlUrl?: string;
|
||||
@@ -56,6 +57,14 @@ export function createClawdbotTools(options?: {
|
||||
config: options?.config,
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
});
|
||||
const webSearchTool = createWebSearchTool({
|
||||
config: options?.config,
|
||||
sandboxed: options?.sandboxed,
|
||||
});
|
||||
const webFetchTool = createWebFetchTool({
|
||||
config: options?.config,
|
||||
sandboxed: options?.sandboxed,
|
||||
});
|
||||
const tools: AnyAgentTool[] = [
|
||||
createBrowserTool({
|
||||
defaultControlUrl: options?.browserControlUrl,
|
||||
@@ -103,6 +112,8 @@ export function createClawdbotTools(options?: {
|
||||
config: options?.config,
|
||||
}),
|
||||
...(memorySearchTool && memoryGetTool ? [memorySearchTool, memoryGetTool] : []),
|
||||
...(webSearchTool ? [webSearchTool] : []),
|
||||
...(webFetchTool ? [webFetchTool] : []),
|
||||
...(imageTool ? [imageTool] : []),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ModelProviderConfig } from "../config/types.js";
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
listProfilesForProvider,
|
||||
resolveApiKeyForProfile,
|
||||
resolveAuthProfileOrder,
|
||||
resolveAuthStorePathForDisplay,
|
||||
} from "./auth-profiles.js";
|
||||
import { normalizeProviderId } from "./model-selection.js";
|
||||
|
||||
@@ -94,7 +97,15 @@ export async function resolveApiKeyForProvider(params: {
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No API key found for provider "${provider}".`);
|
||||
const authStorePath = resolveAuthStorePathForDisplay(params.agentDir);
|
||||
const resolvedAgentDir = path.dirname(authStorePath);
|
||||
throw new Error(
|
||||
[
|
||||
`No API key found for provider "${provider}".`,
|
||||
`Auth store: ${authStorePath} (agentDir: ${resolvedAgentDir}).`,
|
||||
"Configure auth for this agent (clawdbot agents add <id>) or copy auth-profiles.json from the main agentDir.",
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
export type EnvApiKeyResult = { apiKey: string; source: string };
|
||||
|
||||
@@ -26,6 +26,7 @@ export {
|
||||
} from "./pi-embedded-helpers/errors.js";
|
||||
export {
|
||||
downgradeGeminiHistory,
|
||||
downgradeGeminiThinkingBlocks,
|
||||
isGoogleModelApi,
|
||||
sanitizeGoogleTurnOrdering,
|
||||
} from "./pi-embedded-helpers/google.js";
|
||||
|
||||
@@ -26,6 +26,59 @@ type GeminiToolCallBlock = {
|
||||
input?: unknown;
|
||||
};
|
||||
|
||||
type GeminiThinkingBlock = {
|
||||
type?: unknown;
|
||||
thinking?: unknown;
|
||||
thinkingSignature?: unknown;
|
||||
};
|
||||
|
||||
export function downgradeGeminiThinkingBlocks(messages: AgentMessage[]): AgentMessage[] {
|
||||
const out: AgentMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
if (!msg || typeof msg !== "object") {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
const role = (msg as { role?: unknown }).role;
|
||||
if (role !== "assistant") {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
|
||||
if (!Array.isArray(assistantMsg.content)) {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gemini rejects thinking blocks that lack a signature; downgrade to text for safety.
|
||||
let hasDowngraded = false;
|
||||
type AssistantContentBlock = (typeof assistantMsg.content)[number];
|
||||
const nextContent = assistantMsg.content.flatMap((block): AssistantContentBlock[] => {
|
||||
if (!block || typeof block !== "object") return [block as AssistantContentBlock];
|
||||
const record = block as GeminiThinkingBlock;
|
||||
if (record.type !== "thinking") return [block];
|
||||
const signature =
|
||||
typeof record.thinkingSignature === "string" ? record.thinkingSignature.trim() : "";
|
||||
if (signature.length > 0) return [block];
|
||||
const thinking = typeof record.thinking === "string" ? record.thinking : "";
|
||||
const trimmed = thinking.trim();
|
||||
hasDowngraded = true;
|
||||
if (!trimmed) return [];
|
||||
return [{ type: "text" as const, text: thinking }];
|
||||
});
|
||||
|
||||
if (!hasDowngraded) {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
if (nextContent.length === 0) {
|
||||
continue;
|
||||
}
|
||||
out.push({ ...assistantMsg, content: nextContent } as AgentMessage);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[] {
|
||||
const downgradedIds = new Set<string>();
|
||||
const out: AgentMessage[] = [];
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
|
||||
|
||||
describe("sanitizeSessionHistory (google thinking)", () => {
|
||||
it("downgrades thinking blocks without signatures for Google models", async () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const input = [
|
||||
{
|
||||
role: "user",
|
||||
content: "hi",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "reasoning" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionHistory({
|
||||
messages: input,
|
||||
modelApi: "google-antigravity",
|
||||
sessionManager,
|
||||
sessionId: "session:google",
|
||||
});
|
||||
|
||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||
content?: Array<{ type?: string; text?: string }>;
|
||||
};
|
||||
expect(assistant.content?.map((block) => block.type)).toEqual(["text"]);
|
||||
expect(assistant.content?.[0]?.text).toBe("reasoning");
|
||||
});
|
||||
|
||||
it("keeps thinking blocks with signatures for Google models", async () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const input = [
|
||||
{
|
||||
role: "user",
|
||||
content: "hi",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "reasoning", thinkingSignature: "sig" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionHistory({
|
||||
messages: input,
|
||||
modelApi: "google-antigravity",
|
||||
sessionManager,
|
||||
sessionId: "session:google",
|
||||
});
|
||||
|
||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||
content?: Array<{ type?: string; thinking?: string; thinkingSignature?: string }>;
|
||||
};
|
||||
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
|
||||
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
|
||||
expect(assistant.content?.[0]?.thinkingSignature).toBe("sig");
|
||||
});
|
||||
|
||||
it("preserves order when downgrading mixed assistant content", async () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const input = [
|
||||
{
|
||||
role: "user",
|
||||
content: "hi",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "thinking", thinking: "internal note" },
|
||||
{ type: "text", text: "world" },
|
||||
],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionHistory({
|
||||
messages: input,
|
||||
modelApi: "google-antigravity",
|
||||
sessionManager,
|
||||
sessionId: "session:google-mixed",
|
||||
});
|
||||
|
||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||
content?: Array<{ type?: string; text?: string }>;
|
||||
};
|
||||
expect(assistant.content?.map((block) => block.type)).toEqual(["text", "text", "text"]);
|
||||
expect(assistant.content?.[1]?.text).toBe("internal note");
|
||||
});
|
||||
|
||||
it("downgrades only unsigned thinking blocks when mixed with signed ones", async () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const input = [
|
||||
{
|
||||
role: "user",
|
||||
content: "hi",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "signed", thinkingSignature: "sig" },
|
||||
{ type: "thinking", thinking: "unsigned" },
|
||||
],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionHistory({
|
||||
messages: input,
|
||||
modelApi: "google-antigravity",
|
||||
sessionManager,
|
||||
sessionId: "session:google-mixed-signatures",
|
||||
});
|
||||
|
||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||
content?: Array<{ type?: string; thinking?: string; text?: string }>;
|
||||
};
|
||||
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking", "text"]);
|
||||
expect(assistant.content?.[0]?.thinking).toBe("signed");
|
||||
expect(assistant.content?.[1]?.text).toBe("unsigned");
|
||||
});
|
||||
|
||||
it("drops empty unsigned thinking blocks for Google models", async () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const input = [
|
||||
{
|
||||
role: "user",
|
||||
content: "hi",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: " " }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionHistory({
|
||||
messages: input,
|
||||
modelApi: "google-antigravity",
|
||||
sessionManager,
|
||||
sessionId: "session:google-empty",
|
||||
});
|
||||
|
||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant");
|
||||
expect(assistant).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps thinking blocks for non-Google models", async () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const input = [
|
||||
{
|
||||
role: "user",
|
||||
content: "hi",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "reasoning" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionHistory({
|
||||
messages: input,
|
||||
modelApi: "openai",
|
||||
sessionManager,
|
||||
sessionId: "session:openai",
|
||||
});
|
||||
|
||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||
content?: Array<{ type?: string }>;
|
||||
};
|
||||
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
|
||||
});
|
||||
|
||||
it("sanitizes tool call ids for OpenAI-compatible APIs", async () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const longId = `call_${"a".repeat(60)}`;
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: longId, name: "read", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: longId,
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionHistory({
|
||||
messages: input,
|
||||
modelApi: "openai-responses",
|
||||
sessionManager,
|
||||
sessionId: "session:openai",
|
||||
});
|
||||
|
||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const toolCall = assistant.content?.[0] as { id?: string };
|
||||
expect(toolCall.id).toBeDefined();
|
||||
expect(toolCall.id?.length).toBeLessThanOrEqual(40);
|
||||
|
||||
const toolResult = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
expect(toolResult.toolCallId).toBe(toolCall.id);
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,7 @@ export async function compactEmbeddedPiSession(params: {
|
||||
const apiKeyInfo = await getApiKeyForModel({
|
||||
model,
|
||||
cfg: params.config,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
if (model.provider === "github-copilot") {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
||||
import {
|
||||
downgradeGeminiThinkingBlocks,
|
||||
downgradeGeminiHistory,
|
||||
isCompactionFailureError,
|
||||
isGoogleModelApi,
|
||||
@@ -36,6 +37,17 @@ const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
]);
|
||||
const OPENAI_TOOL_CALL_ID_APIS = new Set([
|
||||
"openai",
|
||||
"openai-completions",
|
||||
"openai-responses",
|
||||
"openai-codex-responses",
|
||||
]);
|
||||
|
||||
function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
|
||||
if (!modelApi) return false;
|
||||
return isGoogleModelApi(modelApi) || OPENAI_TOOL_CALL_ID_APIS.has(modelApi);
|
||||
}
|
||||
|
||||
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
|
||||
if (!schema || typeof schema !== "object") return [];
|
||||
@@ -144,14 +156,17 @@ export async function sanitizeSessionHistory(params: {
|
||||
sessionId: string;
|
||||
}): Promise<AgentMessage[]> {
|
||||
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
||||
sanitizeToolCallIds: isGoogleModelApi(params.modelApi),
|
||||
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
|
||||
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
||||
});
|
||||
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
||||
|
||||
const downgraded = isGoogleModelApi(params.modelApi)
|
||||
? downgradeGeminiHistory(repairedTools)
|
||||
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
|
||||
const downgradedThinking = isGoogleModelApi(params.modelApi)
|
||||
? downgradeGeminiThinkingBlocks(repairedTools)
|
||||
: repairedTools;
|
||||
const downgraded = isGoogleModelApi(params.modelApi)
|
||||
? downgradeGeminiHistory(downgradedThinking)
|
||||
: downgradedThinking;
|
||||
|
||||
return applyGoogleTurnOrderingFix({
|
||||
messages: downgraded,
|
||||
|
||||
@@ -133,6 +133,7 @@ export async function runEmbeddedPiAgent(
|
||||
cfg: params.config,
|
||||
profileId: candidate,
|
||||
store: authStore,
|
||||
agentDir,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -129,6 +129,59 @@ describe("Agent-specific tool filtering", () => {
|
||||
expect(toolNames).not.toContain("edit");
|
||||
});
|
||||
|
||||
it("should apply provider-specific tool policy", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
tools: {
|
||||
allow: ["read", "write", "exec"],
|
||||
byProvider: {
|
||||
"google-antigravity": {
|
||||
allow: ["read"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: "/tmp/test-provider",
|
||||
agentDir: "/tmp/agent-provider",
|
||||
modelProvider: "google-antigravity",
|
||||
modelId: "claude-opus-4-5-thinking",
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
expect(toolNames).toContain("read");
|
||||
expect(toolNames).not.toContain("exec");
|
||||
expect(toolNames).not.toContain("write");
|
||||
expect(toolNames).not.toContain("apply_patch");
|
||||
});
|
||||
|
||||
it("should apply provider-specific tool profile overrides", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
tools: {
|
||||
profile: "coding",
|
||||
byProvider: {
|
||||
"google-antigravity": {
|
||||
profile: "minimal",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: "/tmp/test-provider-profile",
|
||||
agentDir: "/tmp/agent-provider-profile",
|
||||
modelProvider: "google-antigravity",
|
||||
modelId: "claude-opus-4-5-thinking",
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
expect(toolNames).toEqual(["session_status"]);
|
||||
});
|
||||
|
||||
it("should allow different tool policies for different agents", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
@@ -178,7 +231,7 @@ describe("Agent-specific tool filtering", () => {
|
||||
expect(familyToolNames).not.toContain("apply_patch");
|
||||
});
|
||||
|
||||
it("should prefer agent-specific tool policy over global", () => {
|
||||
it("should apply global tool policy before agent-specific policy", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
tools: {
|
||||
deny: ["browser"], // Global deny
|
||||
@@ -204,8 +257,8 @@ describe("Agent-specific tool filtering", () => {
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
// Agent policy overrides global: browser is allowed again
|
||||
expect(toolNames).toContain("browser");
|
||||
// Global policy still applies; agent policy further restricts
|
||||
expect(toolNames).not.toContain("browser");
|
||||
expect(toolNames).not.toContain("exec");
|
||||
expect(toolNames).not.toContain("process");
|
||||
expect(toolNames).not.toContain("apply_patch");
|
||||
|
||||
@@ -41,24 +41,87 @@ export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolP
|
||||
return tools.filter((tool) => isToolAllowedByPolicyName(tool.name, policy));
|
||||
}
|
||||
|
||||
type ToolPolicyConfig = {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
profile?: string;
|
||||
};
|
||||
|
||||
function pickToolPolicy(config?: ToolPolicyConfig): SandboxToolPolicy | undefined {
|
||||
if (!config) return undefined;
|
||||
const allow = Array.isArray(config.allow) ? config.allow : undefined;
|
||||
const deny = Array.isArray(config.deny) ? config.deny : undefined;
|
||||
if (!allow && !deny) return undefined;
|
||||
return { allow, deny };
|
||||
}
|
||||
|
||||
function normalizeProviderKey(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function resolveProviderToolPolicy(params: {
|
||||
byProvider?: Record<string, ToolPolicyConfig>;
|
||||
modelProvider?: string;
|
||||
modelId?: string;
|
||||
}): ToolPolicyConfig | undefined {
|
||||
const provider = params.modelProvider?.trim();
|
||||
if (!provider || !params.byProvider) return undefined;
|
||||
|
||||
const entries = Object.entries(params.byProvider);
|
||||
if (entries.length === 0) return undefined;
|
||||
|
||||
const lookup = new Map<string, ToolPolicyConfig>();
|
||||
for (const [key, value] of entries) {
|
||||
const normalized = normalizeProviderKey(key);
|
||||
if (!normalized) continue;
|
||||
lookup.set(normalized, value);
|
||||
}
|
||||
|
||||
const normalizedProvider = normalizeProviderKey(provider);
|
||||
const rawModelId = params.modelId?.trim().toLowerCase();
|
||||
const fullModelId =
|
||||
rawModelId && !rawModelId.includes("/") ? `${normalizedProvider}/${rawModelId}` : rawModelId;
|
||||
|
||||
const candidates = [...(fullModelId ? [fullModelId] : []), normalizedProvider];
|
||||
|
||||
for (const key of candidates) {
|
||||
const match = lookup.get(key);
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveEffectiveToolPolicy(params: {
|
||||
config?: ClawdbotConfig;
|
||||
sessionKey?: string;
|
||||
modelProvider?: string;
|
||||
modelId?: string;
|
||||
}) {
|
||||
const agentId = params.sessionKey ? resolveAgentIdFromSessionKey(params.sessionKey) : undefined;
|
||||
const agentConfig =
|
||||
params.config && agentId ? resolveAgentConfig(params.config, agentId) : undefined;
|
||||
const agentTools = agentConfig?.tools;
|
||||
const hasAgentToolPolicy =
|
||||
Array.isArray(agentTools?.allow) ||
|
||||
Array.isArray(agentTools?.deny) ||
|
||||
typeof agentTools?.profile === "string";
|
||||
const globalTools = params.config?.tools;
|
||||
|
||||
const profile = agentTools?.profile ?? globalTools?.profile;
|
||||
const providerPolicy = resolveProviderToolPolicy({
|
||||
byProvider: globalTools?.byProvider,
|
||||
modelProvider: params.modelProvider,
|
||||
modelId: params.modelId,
|
||||
});
|
||||
const agentProviderPolicy = resolveProviderToolPolicy({
|
||||
byProvider: agentTools?.byProvider,
|
||||
modelProvider: params.modelProvider,
|
||||
modelId: params.modelId,
|
||||
});
|
||||
return {
|
||||
agentId,
|
||||
policy: hasAgentToolPolicy ? agentTools : globalTools,
|
||||
globalPolicy: pickToolPolicy(globalTools),
|
||||
globalProviderPolicy: pickToolPolicy(providerPolicy),
|
||||
agentPolicy: pickToolPolicy(agentTools),
|
||||
agentProviderPolicy: pickToolPolicy(agentProviderPolicy),
|
||||
profile,
|
||||
providerProfile: agentProviderPolicy?.profile ?? providerPolicy?.profile,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+30
-5
@@ -111,13 +111,20 @@ export function createClawdbotCodingTools(options?: {
|
||||
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
|
||||
const {
|
||||
agentId,
|
||||
policy: effectiveToolsPolicy,
|
||||
globalPolicy,
|
||||
globalProviderPolicy,
|
||||
agentPolicy,
|
||||
agentProviderPolicy,
|
||||
profile,
|
||||
providerProfile,
|
||||
} = resolveEffectiveToolPolicy({
|
||||
config: options?.config,
|
||||
sessionKey: options?.sessionKey,
|
||||
modelProvider: options?.modelProvider,
|
||||
modelId: options?.modelId,
|
||||
});
|
||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
||||
const scopeKey = options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
|
||||
const subagentPolicy =
|
||||
isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
|
||||
@@ -125,7 +132,11 @@ export function createClawdbotCodingTools(options?: {
|
||||
: undefined;
|
||||
const allowBackground = isToolAllowedByPolicies("process", [
|
||||
profilePolicy,
|
||||
effectiveToolsPolicy,
|
||||
providerProfilePolicy,
|
||||
globalPolicy,
|
||||
globalProviderPolicy,
|
||||
agentPolicy,
|
||||
agentProviderPolicy,
|
||||
sandbox?.tools,
|
||||
subagentPolicy,
|
||||
]);
|
||||
@@ -229,10 +240,24 @@ export function createClawdbotCodingTools(options?: {
|
||||
}),
|
||||
];
|
||||
const toolsFiltered = profilePolicy ? filterToolsByPolicy(tools, profilePolicy) : tools;
|
||||
const policyFiltered = effectiveToolsPolicy
|
||||
? filterToolsByPolicy(toolsFiltered, effectiveToolsPolicy)
|
||||
const providerProfileFiltered = providerProfilePolicy
|
||||
? filterToolsByPolicy(toolsFiltered, providerProfilePolicy)
|
||||
: toolsFiltered;
|
||||
const sandboxed = sandbox ? filterToolsByPolicy(policyFiltered, sandbox.tools) : policyFiltered;
|
||||
const globalFiltered = globalPolicy
|
||||
? filterToolsByPolicy(providerProfileFiltered, globalPolicy)
|
||||
: providerProfileFiltered;
|
||||
const globalProviderFiltered = globalProviderPolicy
|
||||
? filterToolsByPolicy(globalFiltered, globalProviderPolicy)
|
||||
: globalFiltered;
|
||||
const agentFiltered = agentPolicy
|
||||
? filterToolsByPolicy(globalProviderFiltered, agentPolicy)
|
||||
: globalProviderFiltered;
|
||||
const agentProviderFiltered = agentProviderPolicy
|
||||
? filterToolsByPolicy(agentFiltered, agentProviderPolicy)
|
||||
: agentFiltered;
|
||||
const sandboxed = sandbox
|
||||
? filterToolsByPolicy(agentProviderFiltered, sandbox.tools)
|
||||
: agentProviderFiltered;
|
||||
const subagentFiltered = subagentPolicy
|
||||
? filterToolsByPolicy(sandboxed, subagentPolicy)
|
||||
: sandboxed;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// This module scrubs/normalizes tool schemas to keep Gemini happy.
|
||||
|
||||
// Keywords that Cloud Code Assist API rejects (not compliant with their JSON Schema subset)
|
||||
const UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
|
||||
export const GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
|
||||
"patternProperties",
|
||||
"additionalProperties",
|
||||
"$schema",
|
||||
@@ -254,7 +254,7 @@ function cleanSchemaForGeminiWithDefs(
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) continue;
|
||||
if (GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) continue;
|
||||
|
||||
if (key === "const") {
|
||||
cleaned.enum = [value];
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sendSpy = vi.fn(async () => ({}));
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: vi.fn(async (req: unknown) => {
|
||||
const typed = req as { method?: string; params?: { message?: string } };
|
||||
if (typed.method === "send") {
|
||||
return await sendSpy(typed);
|
||||
}
|
||||
if (typed.method === "agent.wait") {
|
||||
return { status: "error", startedAt: 10, endedAt: 20, error: "boom" };
|
||||
}
|
||||
if (typed.method === "sessions.patch") return {};
|
||||
if (typed.method === "sessions.delete") return {};
|
||||
return {};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./tools/agent-step.js", () => ({
|
||||
runAgentStep: vi.fn(async () => "did some stuff"),
|
||||
readLatestAssistantReply: vi.fn(async () => "raw subagent reply"),
|
||||
}));
|
||||
|
||||
vi.mock("./tools/sessions-announce-target.js", () => ({
|
||||
resolveAnnounceTarget: vi.fn(async () => ({
|
||||
provider: "telegram",
|
||||
to: "+15550001111",
|
||||
accountId: "default",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./tools/sessions-send-helpers.js", () => ({
|
||||
isAnnounceSkip: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
loadSessionStore: vi.fn(async () => ({ entries: {} })),
|
||||
resolveAgentIdFromSessionKey: () => "main",
|
||||
resolveStorePath: () => "/tmp/sessions.json",
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: () => ({
|
||||
session: { mainKey: "agent:main:main" },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("subagent announce formatting", () => {
|
||||
beforeEach(() => {
|
||||
sendSpy.mockClear();
|
||||
});
|
||||
|
||||
it("wraps unstructured announce into Status/Result/Notes", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-123",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "do thing",
|
||||
timeoutMs: 1000,
|
||||
cleanup: "keep",
|
||||
waitForCompletion: true,
|
||||
startedAt: 10,
|
||||
endedAt: 20,
|
||||
});
|
||||
|
||||
expect(sendSpy).toHaveBeenCalled();
|
||||
const msg = sendSpy.mock.calls[0]?.[0]?.params?.message as string;
|
||||
expect(msg).toContain("Status:");
|
||||
expect(msg).toContain("Status: error");
|
||||
expect(msg).toContain("Result:");
|
||||
expect(msg).toContain("Notes:");
|
||||
expect(msg).toContain("boom");
|
||||
});
|
||||
|
||||
it("keeps runtime status even when announce reply is structured", async () => {
|
||||
const agentStep = await import("./tools/agent-step.js");
|
||||
vi.mocked(agentStep.runAgentStep).mockResolvedValueOnce(
|
||||
"- **Status:** success\n\n- **Result:** did some stuff\n\n- **Notes:** all good",
|
||||
);
|
||||
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-456",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "do thing",
|
||||
timeoutMs: 1000,
|
||||
cleanup: "keep",
|
||||
waitForCompletion: true,
|
||||
startedAt: 10,
|
||||
endedAt: 20,
|
||||
});
|
||||
|
||||
const msg = sendSpy.mock.calls[0]?.[0]?.params?.message as string;
|
||||
expect(msg).toContain("Status: error");
|
||||
expect(msg).toContain("Result:");
|
||||
expect(msg).toContain("Notes:");
|
||||
});
|
||||
});
|
||||
@@ -183,6 +183,85 @@ export function buildSubagentSystemPrompt(params: {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export type SubagentRunOutcome = {
|
||||
status: "ok" | "error" | "timeout" | "unknown";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const ANNOUNCE_SECTION_RE = /^\s*[-*]?\s*(?:\*\*)?(status|result|notes)(?:\*\*)?\s*:\s*(.*)$/i;
|
||||
|
||||
function parseAnnounceSections(announce: string) {
|
||||
const sections = {
|
||||
status: [] as string[],
|
||||
result: [] as string[],
|
||||
notes: [] as string[],
|
||||
};
|
||||
let current: keyof typeof sections | null = null;
|
||||
let sawSection = false;
|
||||
|
||||
for (const line of announce.split(/\r?\n/)) {
|
||||
const match = line.match(ANNOUNCE_SECTION_RE);
|
||||
if (match) {
|
||||
const key = match[1]?.toLowerCase() as keyof typeof sections;
|
||||
current = key;
|
||||
sawSection = true;
|
||||
const rest = match[2]?.trim();
|
||||
if (rest) sections[key].push(rest);
|
||||
continue;
|
||||
}
|
||||
if (current) sections[current].push(line);
|
||||
}
|
||||
|
||||
const normalize = (lines: string[]) => {
|
||||
const joined = lines.join("\n").trim();
|
||||
return joined.length > 0 ? joined : undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
sawSection,
|
||||
status: normalize(sections.status),
|
||||
result: normalize(sections.result),
|
||||
notes: normalize(sections.notes),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAnnounceBody(params: {
|
||||
outcome: SubagentRunOutcome;
|
||||
announceReply: string;
|
||||
statsLine?: string;
|
||||
}) {
|
||||
const announce = params.announceReply.trim();
|
||||
const statsLine = params.statsLine?.trim();
|
||||
|
||||
const statusLabel =
|
||||
params.outcome.status === "ok"
|
||||
? "success"
|
||||
: params.outcome.status === "timeout"
|
||||
? "timeout"
|
||||
: params.outcome.status === "unknown"
|
||||
? "unknown"
|
||||
: "error";
|
||||
|
||||
const parsed = parseAnnounceSections(announce);
|
||||
const resultText = parsed.result ?? (announce || "(not available)");
|
||||
const notesParts: string[] = [];
|
||||
if (parsed.notes) notesParts.push(parsed.notes);
|
||||
if (params.outcome.error) notesParts.push(`- Error: ${params.outcome.error}`);
|
||||
const notesBlock = notesParts.length ? notesParts.join("\n") : "- (none)";
|
||||
|
||||
const message = [
|
||||
`Status: ${statusLabel}`,
|
||||
"",
|
||||
"Result:",
|
||||
resultText,
|
||||
"",
|
||||
"Notes:",
|
||||
notesBlock,
|
||||
].join("\n");
|
||||
|
||||
return statsLine ? `${message}\n\n${statsLine}` : message;
|
||||
}
|
||||
|
||||
function buildSubagentAnnouncePrompt(params: {
|
||||
requesterSessionKey?: string;
|
||||
requesterChannel?: string;
|
||||
@@ -202,6 +281,10 @@ function buildSubagentAnnouncePrompt(params: {
|
||||
"",
|
||||
"**You MUST announce your result.** The requester is waiting for your response.",
|
||||
"Provide a brief, useful summary of what you accomplished.",
|
||||
"Reply with Result and Notes only (no Status line; status is added by the system).",
|
||||
"Format:",
|
||||
"Result: <summary>",
|
||||
"Notes: <extra context>",
|
||||
'Only reply "ANNOUNCE_SKIP" if the task completely failed with no useful output.',
|
||||
"Your reply will be posted to the requester chat.",
|
||||
].filter(Boolean);
|
||||
@@ -222,10 +305,12 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
label?: string;
|
||||
outcome?: SubagentRunOutcome;
|
||||
}): Promise<boolean> {
|
||||
let didAnnounce = false;
|
||||
try {
|
||||
let reply = params.roundOneReply;
|
||||
let outcome: SubagentRunOutcome | undefined = params.outcome;
|
||||
if (!reply && params.waitForCompletion !== false) {
|
||||
const waitMs = Math.min(params.timeoutMs, 60_000);
|
||||
const wait = (await callGateway({
|
||||
@@ -235,8 +320,30 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
timeoutMs: waitMs,
|
||||
},
|
||||
timeoutMs: waitMs + 2000,
|
||||
})) as { status?: string };
|
||||
if (wait?.status !== "ok") return false;
|
||||
})) as {
|
||||
status?: string;
|
||||
error?: string;
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
};
|
||||
if (wait?.status === "timeout") {
|
||||
outcome = { status: "timeout" };
|
||||
} else if (wait?.status === "error") {
|
||||
outcome = { status: "error", error: wait.error };
|
||||
} else if (wait?.status === "ok") {
|
||||
outcome = { status: "ok" };
|
||||
}
|
||||
if (typeof wait?.startedAt === "number" && !params.startedAt) {
|
||||
params.startedAt = wait.startedAt;
|
||||
}
|
||||
if (typeof wait?.endedAt === "number" && !params.endedAt) {
|
||||
params.endedAt = wait.endedAt;
|
||||
}
|
||||
if (wait?.status === "timeout") {
|
||||
// No lifecycle end seen before timeout. Still attempt an announce so
|
||||
// requesters are not left hanging.
|
||||
if (!outcome) outcome = { status: "timeout" };
|
||||
}
|
||||
reply = await readLatestAssistantReply({
|
||||
sessionKey: params.childSessionKey,
|
||||
});
|
||||
@@ -248,6 +355,8 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
});
|
||||
}
|
||||
|
||||
if (!outcome) outcome = { status: "unknown" };
|
||||
|
||||
const announceTarget = await resolveAnnounceTarget({
|
||||
sessionKey: params.requesterSessionKey,
|
||||
displayKey: params.requesterDisplayKey,
|
||||
@@ -278,7 +387,11 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
startedAt: params.startedAt,
|
||||
endedAt: params.endedAt,
|
||||
});
|
||||
const message = statsLine ? `${announceReply.trim()}\n\n${statsLine}` : announceReply.trim();
|
||||
const message = normalizeAnnounceBody({
|
||||
outcome,
|
||||
announceReply,
|
||||
statsLine,
|
||||
});
|
||||
|
||||
await callGateway({
|
||||
method: "send",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { onAgentEvent } from "../infra/agent-events.js";
|
||||
import { runSubagentAnnounceFlow } from "./subagent-announce.js";
|
||||
import { runSubagentAnnounceFlow, type SubagentRunOutcome } from "./subagent-announce.js";
|
||||
import {
|
||||
loadSubagentRegistryFromDisk,
|
||||
saveSubagentRegistryToDisk,
|
||||
@@ -20,6 +20,7 @@ export type SubagentRunRecord = {
|
||||
createdAt: number;
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
outcome?: SubagentRunOutcome;
|
||||
archiveAtMs?: number;
|
||||
announceCompletedAt?: number;
|
||||
announceHandled: boolean;
|
||||
@@ -62,6 +63,7 @@ function resumeSubagentRun(runId: string) {
|
||||
startedAt: entry.startedAt,
|
||||
endedAt: entry.endedAt,
|
||||
label: entry.label,
|
||||
outcome: entry.outcome,
|
||||
});
|
||||
void announce.then((didAnnounce) => {
|
||||
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);
|
||||
@@ -176,6 +178,12 @@ function ensureListener() {
|
||||
const endedAt =
|
||||
typeof evt.data?.endedAt === "number" ? (evt.data.endedAt as number) : Date.now();
|
||||
entry.endedAt = endedAt;
|
||||
if (phase === "error") {
|
||||
const error = typeof evt.data?.error === "string" ? (evt.data.error as string) : undefined;
|
||||
entry.outcome = { status: "error", error };
|
||||
} else {
|
||||
entry.outcome = { status: "ok" };
|
||||
}
|
||||
persistSubagentRuns();
|
||||
|
||||
if (!beginSubagentAnnounce(evt.runId)) {
|
||||
@@ -194,6 +202,7 @@ function ensureListener() {
|
||||
startedAt: entry.startedAt,
|
||||
endedAt: entry.endedAt,
|
||||
label: entry.label,
|
||||
outcome: entry.outcome,
|
||||
});
|
||||
void announce.then((didAnnounce) => {
|
||||
finalizeSubagentAnnounce(evt.runId, entry.cleanup, didAnnounce);
|
||||
@@ -272,7 +281,7 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
|
||||
timeoutMs,
|
||||
},
|
||||
timeoutMs: timeoutMs + 10_000,
|
||||
})) as { status?: string; startedAt?: number; endedAt?: number };
|
||||
})) as { status?: string; startedAt?: number; endedAt?: number; error?: string };
|
||||
if (wait?.status !== "ok" && wait?.status !== "error") return;
|
||||
const entry = subagentRuns.get(runId);
|
||||
if (!entry) return;
|
||||
@@ -289,6 +298,9 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
|
||||
entry.endedAt = Date.now();
|
||||
mutated = true;
|
||||
}
|
||||
entry.outcome =
|
||||
wait.status === "error" ? { status: "error", error: wait.error } : { status: "ok" };
|
||||
mutated = true;
|
||||
if (mutated) persistSubagentRuns();
|
||||
if (!beginSubagentAnnounce(runId)) return;
|
||||
const announce = runSubagentAnnounceFlow({
|
||||
@@ -304,6 +316,7 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
|
||||
startedAt: entry.startedAt,
|
||||
endedAt: entry.endedAt,
|
||||
label: entry.label,
|
||||
outcome: entry.outcome,
|
||||
});
|
||||
void announce.then((didAnnounce) => {
|
||||
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);
|
||||
|
||||
@@ -54,6 +54,8 @@ export function buildAgentSystemPrompt(params: {
|
||||
ls: "List directory contents",
|
||||
exec: "Run shell commands",
|
||||
process: "Manage background exec sessions",
|
||||
web_search: "Search the web (Brave API)",
|
||||
web_fetch: "Fetch and extract readable content from a URL",
|
||||
// Channel docking: add login tools here when a channel needs interactive linking.
|
||||
browser: "Control web browser",
|
||||
canvas: "Present/eval/snapshot the Canvas",
|
||||
@@ -81,6 +83,8 @@ export function buildAgentSystemPrompt(params: {
|
||||
"ls",
|
||||
"exec",
|
||||
"process",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"browser",
|
||||
"canvas",
|
||||
"nodes",
|
||||
|
||||
@@ -65,4 +65,48 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
||||
expect(r1.toolCallId).toBe(a.id);
|
||||
expect(r2.toolCallId).toBe(b.id);
|
||||
});
|
||||
|
||||
it("caps tool call IDs at 40 chars while preserving uniqueness", () => {
|
||||
const longA = `call_${"a".repeat(60)}`;
|
||||
const longB = `call_${"a".repeat(59)}b`;
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: longA, name: "read", arguments: {} },
|
||||
{ type: "toolCall", id: longB, name: "read", arguments: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: longA,
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "one" }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: longB,
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "two" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const a = assistant.content?.[0] as { id?: string };
|
||||
const b = assistant.content?.[1] as { id?: string };
|
||||
|
||||
expect(typeof a.id).toBe("string");
|
||||
expect(typeof b.id).toBe("string");
|
||||
expect(a.id).not.toBe(b.id);
|
||||
expect(a.id?.length).toBeLessThanOrEqual(40);
|
||||
expect(b.id?.length).toBeLessThanOrEqual(40);
|
||||
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
|
||||
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true);
|
||||
|
||||
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
expect(r1.toolCallId).toBe(a.id);
|
||||
expect(r2.toolCallId).toBe(b.id);
|
||||
});
|
||||
});
|
||||
@@ -24,21 +24,25 @@ function shortHash(text: string): string {
|
||||
}
|
||||
|
||||
function makeUniqueToolId(params: { id: string; used: Set<string> }): string {
|
||||
const base = sanitizeToolCallId(params.id);
|
||||
const MAX_LEN = 40;
|
||||
|
||||
const base = sanitizeToolCallId(params.id).slice(0, MAX_LEN);
|
||||
if (!params.used.has(base)) return base;
|
||||
|
||||
const hash = shortHash(params.id);
|
||||
const maxBaseLen = 64 - 1 - hash.length;
|
||||
const maxBaseLen = MAX_LEN - 1 - hash.length;
|
||||
const clippedBase = base.length > maxBaseLen ? base.slice(0, maxBaseLen) : base;
|
||||
const candidate = `${clippedBase}_${hash}`;
|
||||
if (!params.used.has(candidate)) return candidate;
|
||||
|
||||
for (let i = 2; i < 1000; i += 1) {
|
||||
const next = `${candidate}_${i}`;
|
||||
const suffix = `_${i}`;
|
||||
const next = `${candidate.slice(0, MAX_LEN - suffix.length)}${suffix}`;
|
||||
if (!params.used.has(next)) return next;
|
||||
}
|
||||
|
||||
return `${candidate}_${Date.now()}`;
|
||||
const ts = `_${Date.now()}`;
|
||||
return `${candidate.slice(0, MAX_LEN - ts.length)}${ts}`;
|
||||
}
|
||||
|
||||
function rewriteAssistantToolCallIds(params: {
|
||||
|
||||
@@ -277,6 +277,16 @@
|
||||
"title": "Memory Get",
|
||||
"detailKeys": ["path", "from", "lines"]
|
||||
},
|
||||
"web_search": {
|
||||
"emoji": "🔎",
|
||||
"title": "Web Search",
|
||||
"detailKeys": ["query", "count"]
|
||||
},
|
||||
"web_fetch": {
|
||||
"emoji": "📄",
|
||||
"title": "Web Fetch",
|
||||
"detailKeys": ["url", "extractMode", "maxChars"]
|
||||
},
|
||||
"whatsapp_login": {
|
||||
"emoji": "🟢",
|
||||
"title": "WhatsApp Login",
|
||||
|
||||
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user