Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f6842fdf4 | ||
|
|
282dbe3167 | ||
|
|
6d749b6e2c | ||
|
|
c9c9516206 | ||
|
|
d3b15c6afa | ||
|
|
f86b24c511 | ||
|
|
b5ddf08763 | ||
|
|
367826f6e4 | ||
|
|
f03c3b3f05 | ||
|
|
ac1b2d8c40 | ||
|
|
2087f0c6a1 | ||
|
|
bcfdcc6820 | ||
|
|
b65acfcbb7 | ||
|
|
f7fcfafb4c | ||
|
|
15606b4d88 | ||
|
|
bb8f08734a | ||
|
|
0b00e591e1 | ||
|
|
e39fd7dbb3 | ||
|
|
b8a82923e9 | ||
|
|
28f8b7bafa | ||
|
|
32dd052260 | ||
|
|
8f7f7ee7dc | ||
|
|
1d8614c7c2 | ||
|
|
436c5fd751 | ||
|
|
f5f7f47c81 | ||
|
|
d4bd387e0e | ||
|
|
d1c85cb32d | ||
|
|
a3a2c641a7 | ||
|
|
54d7551b53 | ||
|
|
e2c10a2b7a | ||
|
|
88b37e80fc | ||
|
|
d5be8fa576 | ||
|
|
208398973b | ||
|
|
8f998741b7 | ||
|
|
9c0ff87c86 | ||
|
|
1a0d1cb7b2 | ||
|
|
cf8b3ed988 | ||
|
|
b7575a889e | ||
|
|
154d4a43db | ||
|
|
b5c023044b | ||
|
|
072a13f3b2 | ||
|
|
c00ea63bb0 | ||
|
|
8350758635 | ||
|
|
2dabce59ce | ||
|
|
e7a4931932 | ||
|
|
ad3c12a43a | ||
|
|
7e2d91f3b7 | ||
|
|
97cef49046 | ||
|
|
016693a1f5 | ||
|
|
89c5185f1c | ||
|
|
b105745299 | ||
|
|
1ae415e395 | ||
|
|
55aff22274 | ||
|
|
e4e1396a98 | ||
|
|
331b8157b0 | ||
|
|
efdb33c975 | ||
|
|
fa1079214b | ||
|
|
82e49af5a7 | ||
|
|
fabc2882aa | ||
|
|
6b3d3f5e21 | ||
|
|
6da6582ced | ||
|
|
45bf07ba31 | ||
|
|
50ae43f886 | ||
|
|
afb877a96b | ||
|
|
0d9172d761 | ||
|
|
dad69afc84 | ||
|
|
787bed4996 | ||
|
|
b6d470a679 | ||
|
|
5fa1a63978 | ||
|
|
6cc57ae772 | ||
|
|
448394a0de |
No files matched your search
+34
-7
@@ -2,25 +2,54 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
|
## 2026.1.18-3
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Exec: add host/security/ask routing for gateway + node exec.
|
||||||
|
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
||||||
|
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
||||||
|
- macOS: add approvals socket UI server + node exec lifecycle events.
|
||||||
|
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
||||||
|
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
||||||
|
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||||
|
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
||||||
|
- Tests: clean up gateway env stubs and assert config.apply sentinel writes. (#1148) — thanks @TSavo.
|
||||||
|
|
||||||
|
## 2026.1.18-2
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
||||||
|
|
||||||
## 2026.1.17-6
|
## 2026.1.17-6
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
|
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
|
||||||
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
|
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
|
||||||
- Docs: document plugin slots and memory plugin behavior.
|
- Docs: document plugin slots and memory plugin behavior.
|
||||||
|
- Plugins: add the bundled BlueBubbles channel plugin (disabled by default).
|
||||||
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
|
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
|
||||||
|
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime.
|
||||||
### Fixes
|
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime.
|
||||||
- Agents: trigger model fallback for prompt-phase failover errors, respecting per-agent overrides. (#1136) — thanks @cheeeee.
|
|
||||||
|
|
||||||
## 2026.1.17-5
|
## 2026.1.17-5
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
|
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
|
||||||
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
|
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
|
||||||
- CLI: surface memory search state in `clawdbot status` and detailed FTS + embedding cache state in `clawdbot memory status`.
|
- CLI: surface FTS + embedding cache state in `clawdbot memory status`.
|
||||||
|
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default.
|
||||||
|
- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
|
||||||
|
- Tools: centralize plugin tool policy helpers.
|
||||||
|
- Commands: add `/subagents info` and show sub-agent counts in `/status`.
|
||||||
|
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
|
||||||
|
|
||||||
## 2026.1.17-4
|
### Fixes
|
||||||
|
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
||||||
|
|
||||||
|
## 2026.1.18-1
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||||
@@ -36,13 +65,11 @@ Docs: https://docs.clawd.bot
|
|||||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||||
- Discord: only emit slow listener warnings after 30s.
|
- Discord: only emit slow listener warnings after 30s.
|
||||||
|
|
||||||
## 2026.1.17-3
|
## 2026.1.17-3
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Memory: add OpenAI Batch API indexing for embeddings when configured.
|
- Memory: add OpenAI Batch API indexing for embeddings when configured.
|
||||||
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
|
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
|
||||||
- Sessions: persist origin metadata across connectors for generic session explainers.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
|
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
|
|||||||
- `/compact` — compact session context (summary)
|
- `/compact` — compact session context (summary)
|
||||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||||
- `/verbose on|off`
|
- `/verbose on|off`
|
||||||
- `/cost on|off` — append per-response token/cost usage lines
|
- `/usage off|tokens|full` — per-response usage footer
|
||||||
- `/restart` — restart the gateway (owner-only in groups)
|
- `/restart` — restart the gateway (owner-only in groups)
|
||||||
- `/activation mention|always` — group activation toggle (groups only)
|
- `/activation mention|always` — group activation toggle (groups only)
|
||||||
|
|
||||||
|
|||||||
@@ -170,8 +170,15 @@ final class AppState {
|
|||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var systemRunPolicy: SystemRunPolicy {
|
var execApprovalMode: ExecApprovalQuickMode {
|
||||||
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
|
didSet {
|
||||||
|
self.ifNotPreview {
|
||||||
|
ExecApprovalsStore.updateDefaults { defaults in
|
||||||
|
defaults.security = self.execApprovalMode.security
|
||||||
|
defaults.ask = self.execApprovalMode.ask
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tracks whether the Canvas panel is currently visible (not persisted).
|
/// Tracks whether the Canvas panel is currently visible (not persisted).
|
||||||
@@ -274,7 +281,8 @@ final class AppState {
|
|||||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||||
self.systemRunPolicy = SystemRunPolicy.load()
|
let execDefaults = ExecApprovalsStore.resolveDefaults()
|
||||||
|
self.execApprovalMode = ExecApprovalQuickMode.from(security: execDefaults.security, ask: execDefaults.ask)
|
||||||
self.peekabooBridgeEnabled = UserDefaults.standard
|
self.peekabooBridgeEnabled = UserDefaults.standard
|
||||||
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
||||||
if !self.isPreview {
|
if !self.isPreview {
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import Security
|
||||||
|
|
||||||
|
enum ExecSecurity: String, CaseIterable, Codable, Identifiable {
|
||||||
|
case deny
|
||||||
|
case allowlist
|
||||||
|
case full
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .deny: "Deny"
|
||||||
|
case .allowlist: "Allowlist"
|
||||||
|
case .full: "Always Allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecApprovalQuickMode: String, CaseIterable, Identifiable {
|
||||||
|
case deny
|
||||||
|
case ask
|
||||||
|
case allow
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .deny: "Deny"
|
||||||
|
case .ask: "Always Ask"
|
||||||
|
case .allow: "Always Allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var security: ExecSecurity {
|
||||||
|
switch self {
|
||||||
|
case .deny: .deny
|
||||||
|
case .ask: .allowlist
|
||||||
|
case .allow: .full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ask: ExecAsk {
|
||||||
|
switch self {
|
||||||
|
case .deny: .off
|
||||||
|
case .ask: .onMiss
|
||||||
|
case .allow: .off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(security: ExecSecurity, ask: ExecAsk) -> ExecApprovalQuickMode {
|
||||||
|
switch security {
|
||||||
|
case .deny:
|
||||||
|
return .deny
|
||||||
|
case .full:
|
||||||
|
return .allow
|
||||||
|
case .allowlist:
|
||||||
|
return .ask
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecAsk: String, CaseIterable, Codable, Identifiable {
|
||||||
|
case off
|
||||||
|
case onMiss = "on-miss"
|
||||||
|
case always
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .off: "Never Ask"
|
||||||
|
case .onMiss: "Ask on Allowlist Miss"
|
||||||
|
case .always: "Always Ask"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecApprovalDecision: String, Codable, Sendable {
|
||||||
|
case allowOnce = "allow-once"
|
||||||
|
case allowAlways = "allow-always"
|
||||||
|
case deny
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecAllowlistEntry: Codable, Hashable {
|
||||||
|
var pattern: String
|
||||||
|
var lastUsedAt: Double? = nil
|
||||||
|
var lastUsedCommand: String? = nil
|
||||||
|
var lastResolvedPath: String? = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsDefaults: Codable {
|
||||||
|
var security: ExecSecurity?
|
||||||
|
var ask: ExecAsk?
|
||||||
|
var askFallback: ExecSecurity?
|
||||||
|
var autoAllowSkills: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsAgent: Codable {
|
||||||
|
var security: ExecSecurity?
|
||||||
|
var ask: ExecAsk?
|
||||||
|
var askFallback: ExecSecurity?
|
||||||
|
var autoAllowSkills: Bool?
|
||||||
|
var allowlist: [ExecAllowlistEntry]?
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
security == nil && ask == nil && askFallback == nil && autoAllowSkills == nil && (allowlist?.isEmpty ?? true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsSocketConfig: Codable {
|
||||||
|
var path: String?
|
||||||
|
var token: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsFile: Codable {
|
||||||
|
var version: Int
|
||||||
|
var socket: ExecApprovalsSocketConfig?
|
||||||
|
var defaults: ExecApprovalsDefaults?
|
||||||
|
var agents: [String: ExecApprovalsAgent]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsResolved {
|
||||||
|
let url: URL
|
||||||
|
let socketPath: String
|
||||||
|
let token: String
|
||||||
|
let defaults: ExecApprovalsResolvedDefaults
|
||||||
|
let agent: ExecApprovalsResolvedDefaults
|
||||||
|
let allowlist: [ExecAllowlistEntry]
|
||||||
|
var file: ExecApprovalsFile
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsResolvedDefaults {
|
||||||
|
var security: ExecSecurity
|
||||||
|
var ask: ExecAsk
|
||||||
|
var askFallback: ExecSecurity
|
||||||
|
var autoAllowSkills: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecApprovalsStore {
|
||||||
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
|
||||||
|
private static let defaultSecurity: ExecSecurity = .deny
|
||||||
|
private static let defaultAsk: ExecAsk = .onMiss
|
||||||
|
private static let defaultAskFallback: ExecSecurity = .deny
|
||||||
|
private static let defaultAutoAllowSkills = false
|
||||||
|
|
||||||
|
static func fileURL() -> URL {
|
||||||
|
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func socketPath() -> String {
|
||||||
|
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadFile() -> ExecApprovalsFile {
|
||||||
|
let url = self.fileURL()
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
||||||
|
if decoded.version != 1 {
|
||||||
|
return ExecApprovalsFile(version: 1, socket: decoded.socket, defaults: decoded.defaults, agents: decoded.agents)
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
} catch {
|
||||||
|
self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveFile(_ file: ExecApprovalsFile) {
|
||||||
|
do {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let data = try encoder.encode(file)
|
||||||
|
let url = self.fileURL()
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
try data.write(to: url, options: [.atomic])
|
||||||
|
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ensureFile() -> ExecApprovalsFile {
|
||||||
|
var file = self.loadFile()
|
||||||
|
if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) }
|
||||||
|
let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if path.isEmpty {
|
||||||
|
file.socket?.path = self.socketPath()
|
||||||
|
}
|
||||||
|
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if token.isEmpty {
|
||||||
|
file.socket?.token = self.generateToken()
|
||||||
|
}
|
||||||
|
if file.agents == nil { file.agents = [:] }
|
||||||
|
self.saveFile(file)
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolve(agentId: String?) -> ExecApprovalsResolved {
|
||||||
|
var file = self.ensureFile()
|
||||||
|
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||||
|
let resolvedDefaults = ExecApprovalsResolvedDefaults(
|
||||||
|
security: defaults.security ?? self.defaultSecurity,
|
||||||
|
ask: defaults.ask ?? self.defaultAsk,
|
||||||
|
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||||
|
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
||||||
|
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||||
|
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
: "default"
|
||||||
|
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
||||||
|
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||||
|
security: agentEntry.security ?? resolvedDefaults.security,
|
||||||
|
ask: agentEntry.ask ?? resolvedDefaults.ask,
|
||||||
|
askFallback: agentEntry.askFallback ?? resolvedDefaults.askFallback,
|
||||||
|
autoAllowSkills: agentEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
|
||||||
|
let allowlist = (agentEntry.allowlist ?? [])
|
||||||
|
.map { entry in
|
||||||
|
ExecAllowlistEntry(
|
||||||
|
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
lastUsedAt: entry.lastUsedAt,
|
||||||
|
lastUsedCommand: entry.lastUsedCommand,
|
||||||
|
lastResolvedPath: entry.lastResolvedPath)
|
||||||
|
}
|
||||||
|
.filter { !$0.pattern.isEmpty }
|
||||||
|
let socketPath = self.expandPath(file.socket?.path ?? self.socketPath())
|
||||||
|
let token = file.socket?.token ?? ""
|
||||||
|
return ExecApprovalsResolved(
|
||||||
|
url: self.fileURL(),
|
||||||
|
socketPath: socketPath,
|
||||||
|
token: token,
|
||||||
|
defaults: resolvedDefaults,
|
||||||
|
agent: resolvedAgent,
|
||||||
|
allowlist: allowlist,
|
||||||
|
file: file)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolveDefaults() -> ExecApprovalsResolvedDefaults {
|
||||||
|
let file = self.ensureFile()
|
||||||
|
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||||
|
return ExecApprovalsResolvedDefaults(
|
||||||
|
security: defaults.security ?? self.defaultSecurity,
|
||||||
|
ask: defaults.ask ?? self.defaultAsk,
|
||||||
|
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||||
|
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveDefaults(_ defaults: ExecApprovalsDefaults) {
|
||||||
|
self.updateFile { file in
|
||||||
|
file.defaults = defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) {
|
||||||
|
self.updateFile { file in
|
||||||
|
var defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||||
|
mutate(&defaults)
|
||||||
|
file.defaults = defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) {
|
||||||
|
self.updateFile { file in
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
if agent.isEmpty {
|
||||||
|
agents.removeValue(forKey: key)
|
||||||
|
} else {
|
||||||
|
agents[key] = agent
|
||||||
|
}
|
||||||
|
file.agents = agents.isEmpty ? nil : agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func addAllowlistEntry(agentId: String?, pattern: String) {
|
||||||
|
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
self.updateFile { file in
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||||
|
var allowlist = entry.allowlist ?? []
|
||||||
|
if allowlist.contains(where: { $0.pattern == trimmed }) { return }
|
||||||
|
allowlist.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: Date().timeIntervalSince1970 * 1000))
|
||||||
|
entry.allowlist = allowlist
|
||||||
|
agents[key] = entry
|
||||||
|
file.agents = agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func recordAllowlistUse(
|
||||||
|
agentId: String?,
|
||||||
|
pattern: String,
|
||||||
|
command: String,
|
||||||
|
resolvedPath: String?)
|
||||||
|
{
|
||||||
|
self.updateFile { file in
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||||
|
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
||||||
|
guard item.pattern == pattern else { return item }
|
||||||
|
return ExecAllowlistEntry(
|
||||||
|
pattern: item.pattern,
|
||||||
|
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
||||||
|
lastUsedCommand: command,
|
||||||
|
lastResolvedPath: resolvedPath)
|
||||||
|
}
|
||||||
|
entry.allowlist = allowlist
|
||||||
|
agents[key] = entry
|
||||||
|
file.agents = agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) {
|
||||||
|
self.updateFile { file in
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||||
|
let cleaned = allowlist
|
||||||
|
.map { item in
|
||||||
|
ExecAllowlistEntry(
|
||||||
|
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
lastUsedAt: item.lastUsedAt,
|
||||||
|
lastUsedCommand: item.lastUsedCommand,
|
||||||
|
lastResolvedPath: item.lastResolvedPath)
|
||||||
|
}
|
||||||
|
.filter { !$0.pattern.isEmpty }
|
||||||
|
entry.allowlist = cleaned
|
||||||
|
agents[key] = entry
|
||||||
|
file.agents = agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) {
|
||||||
|
self.updateFile { file in
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||||
|
mutate(&entry)
|
||||||
|
if entry.isEmpty {
|
||||||
|
agents.removeValue(forKey: key)
|
||||||
|
} else {
|
||||||
|
agents[key] = entry
|
||||||
|
}
|
||||||
|
file.agents = agents.isEmpty ? nil : agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) {
|
||||||
|
var file = self.ensureFile()
|
||||||
|
mutate(&file)
|
||||||
|
self.saveFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func generateToken() -> String {
|
||||||
|
var bytes = [UInt8](repeating: 0, count: 24)
|
||||||
|
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||||
|
if status == errSecSuccess {
|
||||||
|
return Data(bytes)
|
||||||
|
.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
return UUID().uuidString
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func expandPath(_ raw: String) -> String {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed == "~" {
|
||||||
|
return FileManager.default.homeDirectoryForCurrentUser.path
|
||||||
|
}
|
||||||
|
if trimmed.hasPrefix("~/") {
|
||||||
|
let suffix = trimmed.dropFirst(2)
|
||||||
|
return FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(String(suffix)).path
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func agentKey(_ agentId: String?) -> String {
|
||||||
|
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return trimmed.isEmpty ? "default" : trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecCommandResolution: Sendable {
|
||||||
|
let rawExecutable: String
|
||||||
|
let resolvedPath: String?
|
||||||
|
let executableName: String
|
||||||
|
let cwd: String?
|
||||||
|
|
||||||
|
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||||
|
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw
|
||||||
|
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
|
||||||
|
let resolvedPath: String? = {
|
||||||
|
if hasPathSeparator {
|
||||||
|
if expanded.hasPrefix("/") {
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath
|
||||||
|
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
|
||||||
|
}
|
||||||
|
let searchPaths = self.searchPaths(from: env)
|
||||||
|
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
|
||||||
|
}()
|
||||||
|
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
|
||||||
|
return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func searchPaths(from env: [String: String]?) -> [String] {
|
||||||
|
let raw = env?["PATH"]
|
||||||
|
if let raw, !raw.isEmpty {
|
||||||
|
return raw.split(separator: ":").map(String.init)
|
||||||
|
}
|
||||||
|
return CommandResolver.preferredPaths()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecCommandFormatter {
|
||||||
|
static func displayString(for argv: [String]) -> String {
|
||||||
|
argv.map { arg in
|
||||||
|
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return "\"\"" }
|
||||||
|
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
|
||||||
|
if !needsQuotes { return trimmed }
|
||||||
|
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
|
||||||
|
return "\"\(escaped)\""
|
||||||
|
}.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecAllowlistMatcher {
|
||||||
|
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
||||||
|
guard let resolution, !entries.isEmpty else { return nil }
|
||||||
|
let rawExecutable = resolution.rawExecutable
|
||||||
|
let resolvedPath = resolution.resolvedPath
|
||||||
|
let executableName = resolution.executableName
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if pattern.isEmpty { continue }
|
||||||
|
let hasPath = pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
|
||||||
|
if hasPath {
|
||||||
|
let target = resolvedPath ?? rawExecutable
|
||||||
|
if self.matches(pattern: pattern, target: target) { return entry }
|
||||||
|
} else if self.matches(pattern: pattern, target: executableName) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func matches(pattern: String, target: String) -> Bool {
|
||||||
|
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return false }
|
||||||
|
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
|
||||||
|
let normalizedPattern = self.normalizeMatchTarget(expanded)
|
||||||
|
let normalizedTarget = self.normalizeMatchTarget(target)
|
||||||
|
guard let regex = self.regex(for: normalizedPattern) else { return false }
|
||||||
|
let range = NSRange(location: 0, length: normalizedTarget.utf16.count)
|
||||||
|
return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeMatchTarget(_ value: String) -> String {
|
||||||
|
value.replacingOccurrences(of: "\\\\", with: "/").lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func regex(for pattern: String) -> NSRegularExpression? {
|
||||||
|
var regex = "^"
|
||||||
|
var idx = pattern.startIndex
|
||||||
|
while idx < pattern.endIndex {
|
||||||
|
let ch = pattern[idx]
|
||||||
|
if ch == "*" {
|
||||||
|
let next = pattern.index(after: idx)
|
||||||
|
if next < pattern.endIndex, pattern[next] == "*" {
|
||||||
|
regex += ".*"
|
||||||
|
idx = pattern.index(after: next)
|
||||||
|
} else {
|
||||||
|
regex += "[^/]*"
|
||||||
|
idx = next
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == "?" {
|
||||||
|
regex += "."
|
||||||
|
idx = pattern.index(after: idx)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
regex += NSRegularExpression.escapedPattern(for: String(ch))
|
||||||
|
idx = pattern.index(after: idx)
|
||||||
|
}
|
||||||
|
regex += "$"
|
||||||
|
return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecEventPayload: Codable, Sendable {
|
||||||
|
var sessionKey: String
|
||||||
|
var runId: String
|
||||||
|
var host: String
|
||||||
|
var command: String?
|
||||||
|
var exitCode: Int?
|
||||||
|
var timedOut: Bool?
|
||||||
|
var success: Bool?
|
||||||
|
var output: String?
|
||||||
|
var reason: String?
|
||||||
|
|
||||||
|
static func truncateOutput(_ raw: String, maxChars: Int = 20_000) -> String? {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
if trimmed.count <= maxChars { return trimmed }
|
||||||
|
let suffix = trimmed.suffix(maxChars)
|
||||||
|
return "… (truncated) \(suffix)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor SkillBinsCache {
|
||||||
|
static let shared = SkillBinsCache()
|
||||||
|
|
||||||
|
private var bins: Set<String> = []
|
||||||
|
private var lastRefresh: Date?
|
||||||
|
private let refreshInterval: TimeInterval = 90
|
||||||
|
|
||||||
|
func currentBins(force: Bool = false) async -> Set<String> {
|
||||||
|
if force || self.isStale() {
|
||||||
|
await self.refresh()
|
||||||
|
}
|
||||||
|
return self.bins
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
do {
|
||||||
|
let report = try await GatewayConnection.shared.skillsStatus()
|
||||||
|
var next = Set<String>()
|
||||||
|
for skill in report.skills {
|
||||||
|
for bin in skill.requirements.bins {
|
||||||
|
let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty { next.insert(trimmed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.bins = next
|
||||||
|
self.lastRefresh = Date()
|
||||||
|
} catch {
|
||||||
|
if self.lastRefresh == nil {
|
||||||
|
self.bins = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isStale() -> Bool {
|
||||||
|
guard let lastRefresh else { return true }
|
||||||
|
return Date().timeIntervalSince(lastRefresh) > self.refreshInterval
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import AppKit
|
||||||
|
import ClawdbotKit
|
||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
struct ExecApprovalPromptRequest: Codable, Sendable {
|
||||||
|
var command: String
|
||||||
|
var cwd: String?
|
||||||
|
var host: String?
|
||||||
|
var security: String?
|
||||||
|
var ask: String?
|
||||||
|
var agentId: String?
|
||||||
|
var resolvedPath: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExecApprovalSocketRequest: Codable {
|
||||||
|
var type: String
|
||||||
|
var token: String
|
||||||
|
var id: String
|
||||||
|
var request: ExecApprovalPromptRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExecApprovalSocketDecision: Codable {
|
||||||
|
var type: String
|
||||||
|
var id: String
|
||||||
|
var decision: ExecApprovalDecision
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecApprovalsSocketClient {
|
||||||
|
private struct TimeoutError: LocalizedError {
|
||||||
|
var message: String
|
||||||
|
var errorDescription: String? { message }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestDecision(
|
||||||
|
socketPath: String,
|
||||||
|
token: String,
|
||||||
|
request: ExecApprovalPromptRequest,
|
||||||
|
timeoutMs: Int = 15_000) async -> ExecApprovalDecision?
|
||||||
|
{
|
||||||
|
let trimmedPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedPath.isEmpty, !trimmedToken.isEmpty else { return nil }
|
||||||
|
do {
|
||||||
|
return try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: {
|
||||||
|
TimeoutError(message: "exec approvals socket timeout")
|
||||||
|
}, operation: {
|
||||||
|
try await Task.detached {
|
||||||
|
try self.requestDecisionSync(
|
||||||
|
socketPath: trimmedPath,
|
||||||
|
token: trimmedToken,
|
||||||
|
request: request)
|
||||||
|
}.value
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requestDecisionSync(
|
||||||
|
socketPath: String,
|
||||||
|
token: String,
|
||||||
|
request: ExecApprovalPromptRequest) throws -> ExecApprovalDecision?
|
||||||
|
{
|
||||||
|
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||||
|
guard fd >= 0 else {
|
||||||
|
throw NSError(domain: "ExecApprovals", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "socket create failed",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
var addr = sockaddr_un()
|
||||||
|
addr.sun_family = sa_family_t(AF_UNIX)
|
||||||
|
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
||||||
|
if socketPath.utf8.count >= maxLen {
|
||||||
|
throw NSError(domain: "ExecApprovals", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "socket path too long",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
socketPath.withCString { cstr in
|
||||||
|
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
||||||
|
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self)
|
||||||
|
strncpy(raw, cstr, maxLen - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let size = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||||
|
let result = withUnsafePointer(to: &addr) { ptr in
|
||||||
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
|
||||||
|
connect(fd, rebound, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result != 0 {
|
||||||
|
throw NSError(domain: "ExecApprovals", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "socket connect failed",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||||
|
|
||||||
|
let message = ExecApprovalSocketRequest(
|
||||||
|
type: "request",
|
||||||
|
token: token,
|
||||||
|
id: UUID().uuidString,
|
||||||
|
request: request)
|
||||||
|
let data = try JSONEncoder().encode(message)
|
||||||
|
var payload = data
|
||||||
|
payload.append(0x0A)
|
||||||
|
try handle.write(contentsOf: payload)
|
||||||
|
|
||||||
|
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||||
|
let lineData = line.data(using: .utf8)
|
||||||
|
else { return nil }
|
||||||
|
let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData)
|
||||||
|
return response.decision
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
|
||||||
|
var buffer = Data()
|
||||||
|
while buffer.count < maxBytes {
|
||||||
|
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
||||||
|
if chunk.isEmpty { break }
|
||||||
|
buffer.append(chunk)
|
||||||
|
if buffer.contains(0x0A) { break }
|
||||||
|
}
|
||||||
|
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
||||||
|
guard !buffer.isEmpty else { return nil }
|
||||||
|
return String(data: buffer, encoding: .utf8)
|
||||||
|
}
|
||||||
|
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||||
|
return String(data: lineData, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ExecApprovalsPromptServer {
|
||||||
|
static let shared = ExecApprovalsPromptServer()
|
||||||
|
|
||||||
|
private var server: ExecApprovalsSocketServer?
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.server == nil else { return }
|
||||||
|
let approvals = ExecApprovalsStore.resolve(agentId: nil)
|
||||||
|
let server = ExecApprovalsSocketServer(
|
||||||
|
socketPath: approvals.socketPath,
|
||||||
|
token: approvals.token,
|
||||||
|
onPrompt: { request in
|
||||||
|
await ExecApprovalsPromptPresenter.prompt(request)
|
||||||
|
})
|
||||||
|
server.start()
|
||||||
|
self.server = server
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.server?.stop()
|
||||||
|
self.server = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ExecApprovalsPromptPresenter {
|
||||||
|
@MainActor
|
||||||
|
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.messageText = "Allow this command?"
|
||||||
|
|
||||||
|
var details = "Clawdbot wants to run:\n\n\(request.command)"
|
||||||
|
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedCwd.isEmpty {
|
||||||
|
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
||||||
|
}
|
||||||
|
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedAgent.isEmpty {
|
||||||
|
details += "\n\nAgent:\n\(trimmedAgent)"
|
||||||
|
}
|
||||||
|
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedPath.isEmpty {
|
||||||
|
details += "\n\nExecutable:\n\(trimmedPath)"
|
||||||
|
}
|
||||||
|
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedHost.isEmpty {
|
||||||
|
details += "\n\nHost:\n\(trimmedHost)"
|
||||||
|
}
|
||||||
|
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
|
||||||
|
details += "\n\nSecurity:\n\(security)"
|
||||||
|
}
|
||||||
|
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
|
||||||
|
details += "\nAsk mode:\n\(ask)"
|
||||||
|
}
|
||||||
|
details += "\n\nThis runs on this machine."
|
||||||
|
alert.informativeText = details
|
||||||
|
|
||||||
|
alert.addButton(withTitle: "Allow Once")
|
||||||
|
alert.addButton(withTitle: "Always Allow")
|
||||||
|
alert.addButton(withTitle: "Don't Allow")
|
||||||
|
|
||||||
|
switch alert.runModal() {
|
||||||
|
case .alertFirstButtonReturn:
|
||||||
|
return .allowOnce
|
||||||
|
case .alertSecondButtonReturn:
|
||||||
|
return .allowAlways
|
||||||
|
default:
|
||||||
|
return .deny
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ExecApprovalsSocketServer {
|
||||||
|
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
|
||||||
|
private let socketPath: String
|
||||||
|
private let token: String
|
||||||
|
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
|
||||||
|
private var socketFD: Int32 = -1
|
||||||
|
private var acceptTask: Task<Void, Never>?
|
||||||
|
private var isRunning = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
socketPath: String,
|
||||||
|
token: String,
|
||||||
|
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision)
|
||||||
|
{
|
||||||
|
self.socketPath = socketPath
|
||||||
|
self.token = token
|
||||||
|
self.onPrompt = onPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !self.isRunning else { return }
|
||||||
|
self.isRunning = true
|
||||||
|
self.acceptTask = Task.detached { [weak self] in
|
||||||
|
await self?.runAcceptLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.isRunning = false
|
||||||
|
self.acceptTask?.cancel()
|
||||||
|
self.acceptTask = nil
|
||||||
|
if self.socketFD >= 0 {
|
||||||
|
close(self.socketFD)
|
||||||
|
self.socketFD = -1
|
||||||
|
}
|
||||||
|
if !self.socketPath.isEmpty {
|
||||||
|
unlink(self.socketPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runAcceptLoop() async {
|
||||||
|
let fd = self.openSocket()
|
||||||
|
guard fd >= 0 else {
|
||||||
|
self.isRunning = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.socketFD = fd
|
||||||
|
while self.isRunning {
|
||||||
|
var addr = sockaddr_un()
|
||||||
|
var len = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||||
|
let client = withUnsafeMutablePointer(to: &addr) { ptr in
|
||||||
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
|
||||||
|
accept(fd, rebound, &len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if client < 0 {
|
||||||
|
if errno == EINTR { continue }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
await self?.handleClient(fd: client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openSocket() -> Int32 {
|
||||||
|
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||||
|
guard fd >= 0 else {
|
||||||
|
self.logger.error("exec approvals socket create failed")
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
unlink(self.socketPath)
|
||||||
|
var addr = sockaddr_un()
|
||||||
|
addr.sun_family = sa_family_t(AF_UNIX)
|
||||||
|
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
||||||
|
if self.socketPath.utf8.count >= maxLen {
|
||||||
|
self.logger.error("exec approvals socket path too long")
|
||||||
|
close(fd)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
self.socketPath.withCString { cstr in
|
||||||
|
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
||||||
|
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self)
|
||||||
|
memset(raw, 0, maxLen)
|
||||||
|
strncpy(raw, cstr, maxLen - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let size = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||||
|
let result = withUnsafePointer(to: &addr) { ptr in
|
||||||
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
|
||||||
|
bind(fd, rebound, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result != 0 {
|
||||||
|
self.logger.error("exec approvals socket bind failed")
|
||||||
|
close(fd)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if listen(fd, 16) != 0 {
|
||||||
|
self.logger.error("exec approvals socket listen failed")
|
||||||
|
close(fd)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
chmod(self.socketPath, 0o600)
|
||||||
|
self.logger.info("exec approvals socket listening at \(self.socketPath, privacy: .public)")
|
||||||
|
return fd
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleClient(fd: Int32) async {
|
||||||
|
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||||
|
do {
|
||||||
|
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||||
|
let data = line.data(using: .utf8)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
|
||||||
|
guard request.type == "request", request.token == self.token else {
|
||||||
|
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny)
|
||||||
|
let data = try JSONEncoder().encode(response)
|
||||||
|
var payload = data
|
||||||
|
payload.append(0x0A)
|
||||||
|
try handle.write(contentsOf: payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let decision = await self.onPrompt(request.request)
|
||||||
|
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: decision)
|
||||||
|
let responseData = try JSONEncoder().encode(response)
|
||||||
|
var payload = responseData
|
||||||
|
payload.append(0x0A)
|
||||||
|
try handle.write(contentsOf: payload)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
|
||||||
|
var buffer = Data()
|
||||||
|
while buffer.count < maxBytes {
|
||||||
|
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
||||||
|
if chunk.isEmpty { break }
|
||||||
|
buffer.append(chunk)
|
||||||
|
if buffer.contains(0x0A) { break }
|
||||||
|
}
|
||||||
|
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
||||||
|
guard !buffer.isEmpty else { return nil }
|
||||||
|
return String(data: buffer, encoding: .utf8)
|
||||||
|
}
|
||||||
|
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||||
|
return String(data: lineData, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
|
|
||||||
enum MacNodeConfigFile {
|
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "mac-node-config")
|
|
||||||
|
|
||||||
static func url() -> URL {
|
|
||||||
ClawdbotPaths.stateDirURL.appendingPathComponent("macos-node.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func loadDict() -> [String: Any] {
|
|
||||||
let url = self.url()
|
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
|
||||||
do {
|
|
||||||
let data = try Data(contentsOf: url)
|
|
||||||
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
||||||
self.logger.warning("mac node config JSON root invalid")
|
|
||||||
return [:]
|
|
||||||
}
|
|
||||||
return root
|
|
||||||
} catch {
|
|
||||||
self.logger.warning("mac node config read failed: \(error.localizedDescription, privacy: .public)")
|
|
||||||
return [:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func saveDict(_ dict: [String: Any]) {
|
|
||||||
do {
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
|
||||||
let url = self.url()
|
|
||||||
try FileManager.default.createDirectory(
|
|
||||||
at: url.deletingLastPathComponent(),
|
|
||||||
withIntermediateDirectories: true)
|
|
||||||
try data.write(to: url, options: [.atomic])
|
|
||||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
|
||||||
} catch {
|
|
||||||
self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func systemRunSection(from root: [String: Any]) -> [String: Any] {
|
|
||||||
root["systemRun"] as? [String: Any] ?? [:]
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func updateSystemRunSection(_ mutate: (inout [String: Any]) -> Void) {
|
|
||||||
var root = self.loadDict()
|
|
||||||
var systemRun = self.systemRunSection(from: root)
|
|
||||||
mutate(&systemRun)
|
|
||||||
if systemRun.isEmpty {
|
|
||||||
root.removeValue(forKey: "systemRun")
|
|
||||||
} else {
|
|
||||||
root["systemRun"] = systemRun
|
|
||||||
}
|
|
||||||
self.saveDict(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func agentSection(_ systemRun: [String: Any], agentId: String) -> [String: Any]? {
|
|
||||||
let agents = systemRun["agents"] as? [String: Any]
|
|
||||||
return agents?[agentId] as? [String: Any]
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func updateAgentSection(
|
|
||||||
_ systemRun: inout [String: Any],
|
|
||||||
agentId: String,
|
|
||||||
mutate: (inout [String: Any]) -> Void)
|
|
||||||
{
|
|
||||||
var agents = systemRun["agents"] as? [String: Any] ?? [:]
|
|
||||||
var entry = agents[agentId] as? [String: Any] ?? [:]
|
|
||||||
mutate(&entry)
|
|
||||||
if entry.isEmpty {
|
|
||||||
agents.removeValue(forKey: agentId)
|
|
||||||
} else {
|
|
||||||
agents[agentId] = entry
|
|
||||||
}
|
|
||||||
if agents.isEmpty {
|
|
||||||
systemRun.removeValue(forKey: "agents")
|
|
||||||
} else {
|
|
||||||
systemRun["agents"] = agents
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func systemRunPolicy(agentId: String? = nil) -> SystemRunPolicy? {
|
|
||||||
let root = self.loadDict()
|
|
||||||
let systemRun = self.systemRunSection(from: root)
|
|
||||||
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
|
|
||||||
let raw = agent["policy"] as? String
|
|
||||||
if let raw, let policy = SystemRunPolicy(rawValue: raw) { return policy }
|
|
||||||
}
|
|
||||||
let raw = systemRun["policy"] as? String
|
|
||||||
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
|
|
||||||
static func setSystemRunPolicy(_ policy: SystemRunPolicy, agentId: String? = nil) {
|
|
||||||
self.updateSystemRunSection { systemRun in
|
|
||||||
if let agentId {
|
|
||||||
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
|
|
||||||
entry["policy"] = policy.rawValue
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
systemRun["policy"] = policy.rawValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func systemRunAutoAllowSkills(agentId: String?) -> Bool? {
|
|
||||||
let root = self.loadDict()
|
|
||||||
let systemRun = self.systemRunSection(from: root)
|
|
||||||
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
|
|
||||||
if let value = agent["autoAllowSkills"] as? Bool { return value }
|
|
||||||
}
|
|
||||||
return systemRun["autoAllowSkills"] as? Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
static func setSystemRunAutoAllowSkills(_ enabled: Bool, agentId: String?) {
|
|
||||||
self.updateSystemRunSection { systemRun in
|
|
||||||
if let agentId {
|
|
||||||
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
|
|
||||||
entry["autoAllowSkills"] = enabled
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
systemRun["autoAllowSkills"] = enabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func systemRunAllowlist(agentId: String?) -> [SystemRunAllowlistEntry]? {
|
|
||||||
let root = self.loadDict()
|
|
||||||
let systemRun = self.systemRunSection(from: root)
|
|
||||||
let raw: [Any]? = {
|
|
||||||
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
|
|
||||||
return agent["allowlist"] as? [Any]
|
|
||||||
}
|
|
||||||
return systemRun["allowlist"] as? [Any]
|
|
||||||
}()
|
|
||||||
guard let raw else { return nil }
|
|
||||||
|
|
||||||
if raw.allSatisfy({ $0 is String }) {
|
|
||||||
let legacy = raw.compactMap { $0 as? String }
|
|
||||||
return legacy.compactMap { key in
|
|
||||||
let pattern = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !pattern.isEmpty else { return nil }
|
|
||||||
return SystemRunAllowlistEntry(
|
|
||||||
pattern: pattern,
|
|
||||||
enabled: true,
|
|
||||||
matchKind: .argv,
|
|
||||||
source: .manual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return raw.compactMap { item in
|
|
||||||
guard let dict = item as? [String: Any] else { return nil }
|
|
||||||
return SystemRunAllowlistEntry(dict: dict)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func setSystemRunAllowlist(_ allowlist: [SystemRunAllowlistEntry], agentId: String?) {
|
|
||||||
let cleaned = allowlist
|
|
||||||
.map { $0 }
|
|
||||||
.filter { !$0.pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
||||||
let raw = cleaned.map { $0.asDict() }
|
|
||||||
self.updateSystemRunSection { systemRun in
|
|
||||||
if let agentId {
|
|
||||||
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
|
|
||||||
if raw.isEmpty {
|
|
||||||
entry.removeValue(forKey: "allowlist")
|
|
||||||
} else {
|
|
||||||
entry["allowlist"] = raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if raw.isEmpty {
|
|
||||||
systemRun.removeValue(forKey: "allowlist")
|
|
||||||
} else {
|
|
||||||
systemRun["allowlist"] = raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func systemRunAllowlistStrings() -> [String]? {
|
|
||||||
let root = self.loadDict()
|
|
||||||
let systemRun = self.systemRunSection(from: root)
|
|
||||||
return systemRun["allowlist"] as? [String]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func setSystemRunAllowlistStrings(_ allowlist: [String]) {
|
|
||||||
let cleaned = allowlist
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
self.updateSystemRunSection { systemRun in
|
|
||||||
if cleaned.isEmpty {
|
|
||||||
systemRun.removeValue(forKey: "allowlist")
|
|
||||||
} else {
|
|
||||||
systemRun["allowlist"] = cleaned
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -256,6 +256,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
TerminationSignalWatcher.shared.start()
|
TerminationSignalWatcher.shared.start()
|
||||||
NodePairingApprovalPrompter.shared.start()
|
NodePairingApprovalPrompter.shared.start()
|
||||||
|
ExecApprovalsPromptServer.shared.start()
|
||||||
MacNodeModeCoordinator.shared.start()
|
MacNodeModeCoordinator.shared.start()
|
||||||
VoiceWakeGlobalSettingsSync.shared.start()
|
VoiceWakeGlobalSettingsSync.shared.start()
|
||||||
Task { PresenceReporter.shared.start() }
|
Task { PresenceReporter.shared.start() }
|
||||||
@@ -280,6 +281,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
PresenceReporter.shared.stop()
|
PresenceReporter.shared.stop()
|
||||||
NodePairingApprovalPrompter.shared.stop()
|
NodePairingApprovalPrompter.shared.stop()
|
||||||
|
ExecApprovalsPromptServer.shared.stop()
|
||||||
MacNodeModeCoordinator.shared.stop()
|
MacNodeModeCoordinator.shared.stop()
|
||||||
TerminationSignalWatcher.shared.stop()
|
TerminationSignalWatcher.shared.stop()
|
||||||
VoiceWakeGlobalSettingsSync.shared.stop()
|
VoiceWakeGlobalSettingsSync.shared.stop()
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ struct MenuContent: View {
|
|||||||
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var systemRunPolicyBinding: Binding<SystemRunPolicy> {
|
private var execApprovalModeBinding: Binding<ExecApprovalQuickMode> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { self.state.systemRunPolicy },
|
get: { self.state.execApprovalMode },
|
||||||
set: { self.state.systemRunPolicy = $0 })
|
set: { self.state.execApprovalMode = $0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -74,12 +74,12 @@ struct MenuContent: View {
|
|||||||
Toggle(isOn: self.$cameraEnabled) {
|
Toggle(isOn: self.$cameraEnabled) {
|
||||||
Label("Allow Camera", systemImage: "camera")
|
Label("Allow Camera", systemImage: "camera")
|
||||||
}
|
}
|
||||||
Picker(selection: self.systemRunPolicyBinding) {
|
Picker(selection: self.execApprovalModeBinding) {
|
||||||
ForEach(SystemRunPolicy.allCases) { policy in
|
ForEach(ExecApprovalQuickMode.allCases) { mode in
|
||||||
Text(policy.title).tag(policy)
|
Text(mode.title).tag(mode)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Node Run Commands", systemImage: "terminal")
|
Label("Exec Approvals", systemImage: "terminal")
|
||||||
}
|
}
|
||||||
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
||||||
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ final class MacNodeModeCoordinator {
|
|||||||
private func run() async {
|
private func run() async {
|
||||||
var retryDelay: UInt64 = 1_000_000_000
|
var retryDelay: UInt64 = 1_000_000_000
|
||||||
var lastCameraEnabled: Bool?
|
var lastCameraEnabled: Bool?
|
||||||
var lastSystemRunPolicy: SystemRunPolicy?
|
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
||||||
@@ -60,15 +59,6 @@ final class MacNodeModeCoordinator {
|
|||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
let systemRunPolicy = SystemRunPolicy.load()
|
|
||||||
if lastSystemRunPolicy == nil {
|
|
||||||
lastSystemRunPolicy = systemRunPolicy
|
|
||||||
} else if lastSystemRunPolicy != systemRunPolicy {
|
|
||||||
lastSystemRunPolicy = systemRunPolicy
|
|
||||||
await self.session.disconnect()
|
|
||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
||||||
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
||||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||||
@@ -89,8 +79,13 @@ final class MacNodeModeCoordinator {
|
|||||||
if let mainSessionKey {
|
if let mainSessionKey {
|
||||||
await self?.runtime.updateMainSessionKey(mainSessionKey)
|
await self?.runtime.updateMainSessionKey(mainSessionKey)
|
||||||
}
|
}
|
||||||
|
await self?.runtime.setEventSender { [weak self] event, payload in
|
||||||
|
guard let self else { return }
|
||||||
|
try? await self.session.sendEvent(event: event, payloadJSON: payload)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDisconnected: { reason in
|
onDisconnected: { [weak self] reason in
|
||||||
|
await self?.runtime.setEventSender(nil)
|
||||||
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
|
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
|
||||||
},
|
},
|
||||||
onInvoke: { [weak self] req in
|
onInvoke: { [weak self] req in
|
||||||
@@ -161,13 +156,10 @@ final class MacNodeModeCoordinator {
|
|||||||
ClawdbotCanvasA2UICommand.reset.rawValue,
|
ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||||
MacNodeScreenCommand.record.rawValue,
|
MacNodeScreenCommand.record.rawValue,
|
||||||
ClawdbotSystemCommand.notify.rawValue,
|
ClawdbotSystemCommand.notify.rawValue,
|
||||||
|
ClawdbotSystemCommand.which.rawValue,
|
||||||
|
ClawdbotSystemCommand.run.rawValue,
|
||||||
]
|
]
|
||||||
|
|
||||||
if SystemRunPolicy.load() != .never {
|
|
||||||
commands.append(ClawdbotSystemCommand.which.rawValue)
|
|
||||||
commands.append(ClawdbotSystemCommand.run.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
let capsSet = Set(caps)
|
let capsSet = Set(caps)
|
||||||
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
|
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
|
||||||
commands.append(ClawdbotCameraCommand.list.rawValue)
|
commands.append(ClawdbotCameraCommand.list.rawValue)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ actor MacNodeRuntime {
|
|||||||
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
|
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
|
||||||
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
|
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
|
||||||
private var mainSessionKey: String = "main"
|
private var mainSessionKey: String = "main"
|
||||||
|
private var eventSender: (@Sendable (String, String?) async -> Void)?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
|
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
|
||||||
@@ -23,6 +24,10 @@ actor MacNodeRuntime {
|
|||||||
self.mainSessionKey = trimmed
|
self.mainSessionKey = trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setEventSender(_ sender: (@Sendable (String, String?) async -> Void)?) {
|
||||||
|
self.eventSender = sender
|
||||||
|
}
|
||||||
|
|
||||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||||
let command = req.command
|
let command = req.command
|
||||||
if self.isCanvasCommand(command), !Self.canvasEnabled() {
|
if self.isCanvasCommand(command), !Self.canvasEnabled() {
|
||||||
@@ -430,14 +435,19 @@ actor MacNodeRuntime {
|
|||||||
|
|
||||||
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
||||||
let policy = SystemRunPolicy.load(agentId: agentId)
|
let approvals = ExecApprovalsStore.resolve(agentId: agentId)
|
||||||
let allowlistEntries = SystemRunAllowlistStore.load(agentId: agentId)
|
let security = approvals.agent.security
|
||||||
let resolution = SystemRunCommandResolution.resolve(command: command, cwd: params.cwd)
|
let ask = approvals.agent.ask
|
||||||
let allowlistMatch = SystemRunAllowlistStore.match(
|
let askFallback = approvals.agent.askFallback
|
||||||
command: command,
|
let autoAllowSkills = approvals.agent.autoAllowSkills
|
||||||
resolution: resolution,
|
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||||
entries: allowlistEntries)
|
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false
|
: self.mainSessionKey
|
||||||
|
let runId = UUID().uuidString
|
||||||
|
let resolution = ExecCommandResolution.resolve(command: command, cwd: params.cwd, env: params.env)
|
||||||
|
let allowlistMatch = security == .allowlist
|
||||||
|
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
||||||
|
: nil
|
||||||
let skillAllow: Bool
|
let skillAllow: Bool
|
||||||
if autoAllowSkills, let name = resolution?.executableName {
|
if autoAllowSkills, let name = resolution?.executableName {
|
||||||
let bins = await SkillBinsCache.shared.currentBins()
|
let bins = await SkillBinsCache.shared.currentBins()
|
||||||
@@ -446,55 +456,90 @@ actor MacNodeRuntime {
|
|||||||
skillAllow = false
|
skillAllow = false
|
||||||
}
|
}
|
||||||
|
|
||||||
let shouldPrompt: Bool = {
|
if security == .deny {
|
||||||
if policy == .never { return false }
|
await self.emitExecEvent(
|
||||||
if allowlistMatch != nil { return false }
|
"exec.denied",
|
||||||
if skillAllow { return false }
|
payload: ExecEventPayload(
|
||||||
return policy == .ask
|
sessionKey: sessionKey,
|
||||||
}()
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
switch policy {
|
command: ExecCommandFormatter.displayString(for: command),
|
||||||
case .never:
|
reason: "security=deny"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
code: .unavailable,
|
code: .unavailable,
|
||||||
message: "SYSTEM_RUN_DISABLED: policy=never")
|
message: "SYSTEM_RUN_DISABLED: security=deny")
|
||||||
case .always:
|
}
|
||||||
break
|
|
||||||
case .ask:
|
let requiresAsk: Bool = {
|
||||||
if shouldPrompt {
|
if ask == .always { return true }
|
||||||
let services = await self.mainActorServices()
|
if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true }
|
||||||
let decision = await services.confirmSystemRun(context: SystemRunPromptContext(
|
return false
|
||||||
command: SystemRunAllowlist.displayString(for: command),
|
}()
|
||||||
|
|
||||||
|
if requiresAsk {
|
||||||
|
let decision = await ExecApprovalsSocketClient.requestDecision(
|
||||||
|
socketPath: approvals.socketPath,
|
||||||
|
token: approvals.token,
|
||||||
|
request: ExecApprovalPromptRequest(
|
||||||
|
command: ExecCommandFormatter.displayString(for: command),
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
|
host: "node",
|
||||||
|
security: security.rawValue,
|
||||||
|
ask: ask.rawValue,
|
||||||
agentId: agentId,
|
agentId: agentId,
|
||||||
executablePath: resolution?.resolvedPath))
|
resolvedPath: resolution?.resolvedPath))
|
||||||
switch decision {
|
|
||||||
case .allowOnce:
|
switch decision {
|
||||||
break
|
case .deny?:
|
||||||
case .allowAlways:
|
await self.emitExecEvent(
|
||||||
if let resolvedPath = resolution?.resolvedPath, !resolvedPath.isEmpty {
|
"exec.denied",
|
||||||
_ = SystemRunAllowlistStore.add(pattern: resolvedPath, agentId: agentId)
|
payload: ExecEventPayload(
|
||||||
} else if let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
|
sessionKey: sessionKey,
|
||||||
!raw.isEmpty
|
runId: runId,
|
||||||
{
|
host: "node",
|
||||||
_ = SystemRunAllowlistStore.add(pattern: raw, agentId: agentId)
|
command: ExecCommandFormatter.displayString(for: command),
|
||||||
}
|
reason: "user-denied"))
|
||||||
case .deny:
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .unavailable,
|
||||||
|
message: "SYSTEM_RUN_DENIED: user denied")
|
||||||
|
case nil:
|
||||||
|
if askFallback == .deny || (askFallback == .allowlist && allowlistMatch == nil && !skillAllow) {
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: ExecCommandFormatter.displayString(for: command),
|
||||||
|
reason: "approval-required"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
code: .unavailable,
|
code: .unavailable,
|
||||||
message: "SYSTEM_RUN_DENIED: user denied")
|
message: "SYSTEM_RUN_DENIED: approval required")
|
||||||
}
|
}
|
||||||
|
case .allowAlways?:
|
||||||
|
if security == .allowlist {
|
||||||
|
let pattern = resolution?.resolvedPath ??
|
||||||
|
resolution?.rawExecutable ??
|
||||||
|
command.first?.trimmingCharacters(in: .whitespacesAndNewlines) ??
|
||||||
|
""
|
||||||
|
if !pattern.isEmpty {
|
||||||
|
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .allowOnce?:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let match = allowlistMatch {
|
if let match = allowlistMatch {
|
||||||
SystemRunAllowlistStore.markUsed(
|
ExecApprovalsStore.recordAllowlistUse(
|
||||||
entryId: match.id,
|
agentId: agentId,
|
||||||
command: command,
|
pattern: match.pattern,
|
||||||
resolvedPath: resolution?.resolvedPath,
|
command: ExecCommandFormatter.displayString(for: command),
|
||||||
agentId: agentId)
|
resolvedPath: resolution?.resolvedPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
let env = Self.sanitizedEnv(params.env)
|
let env = Self.sanitizedEnv(params.env)
|
||||||
@@ -503,6 +548,14 @@ actor MacNodeRuntime {
|
|||||||
let authorized = await PermissionManager
|
let authorized = await PermissionManager
|
||||||
.status([.screenRecording])[.screenRecording] ?? false
|
.status([.screenRecording])[.screenRecording] ?? false
|
||||||
if !authorized {
|
if !authorized {
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: ExecCommandFormatter.displayString(for: command),
|
||||||
|
reason: "permission:screenRecording"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
code: .unavailable,
|
code: .unavailable,
|
||||||
@@ -511,11 +564,30 @@ actor MacNodeRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
|
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.started",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: ExecCommandFormatter.displayString(for: command)))
|
||||||
let result = await ShellExecutor.runDetailed(
|
let result = await ShellExecutor.runDetailed(
|
||||||
command: command,
|
command: command,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
env: env,
|
env: env,
|
||||||
timeout: timeoutSec)
|
timeout: timeoutSec)
|
||||||
|
let combined = [result.stdout, result.stderr, result.errorMessage].filter { !$0.isEmpty }.joined(separator: "\n")
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.finished",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: ExecCommandFormatter.displayString(for: command),
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
timedOut: result.timedOut,
|
||||||
|
success: result.success,
|
||||||
|
output: ExecEventPayload.truncateOutput(combined)))
|
||||||
|
|
||||||
struct RunPayload: Encodable {
|
struct RunPayload: Encodable {
|
||||||
var exitCode: Int?
|
var exitCode: Int?
|
||||||
@@ -563,6 +635,16 @@ actor MacNodeRuntime {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
|
||||||
|
guard let sender = self.eventSender else { return }
|
||||||
|
guard let data = try? JSONEncoder().encode(payload),
|
||||||
|
let json = String(data: data, encoding: .utf8)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await sender(event, json)
|
||||||
|
}
|
||||||
|
|
||||||
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
||||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -629,10 +711,6 @@ actor MacNodeRuntime {
|
|||||||
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func systemRunPolicy() -> SystemRunPolicy {
|
|
||||||
SystemRunPolicy.load()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let blockedEnvKeys: Set<String> = [
|
private static let blockedEnvKeys: Set<String> = [
|
||||||
"PATH",
|
"PATH",
|
||||||
"NODE_OPTIONS",
|
"NODE_OPTIONS",
|
||||||
|
|||||||
@@ -1,21 +1,7 @@
|
|||||||
import AppKit
|
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum SystemRunDecision: Sendable {
|
|
||||||
case allowOnce
|
|
||||||
case allowAlways
|
|
||||||
case deny
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SystemRunPromptContext: Sendable {
|
|
||||||
let command: String
|
|
||||||
let cwd: String?
|
|
||||||
let agentId: String?
|
|
||||||
let executablePath: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol MacNodeRuntimeMainActorServices: Sendable {
|
protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||||
func recordScreen(
|
func recordScreen(
|
||||||
@@ -31,8 +17,6 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
|
|||||||
desiredAccuracy: ClawdbotLocationAccuracy,
|
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||||
maxAgeMs: Int?,
|
maxAgeMs: Int?,
|
||||||
timeoutMs: Int?) async throws -> CLLocation
|
timeoutMs: Int?) async throws -> CLLocation
|
||||||
|
|
||||||
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -74,38 +58,4 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
|
|||||||
timeoutMs: timeoutMs)
|
timeoutMs: timeoutMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision {
|
|
||||||
let alert = NSAlert()
|
|
||||||
alert.alertStyle = .warning
|
|
||||||
alert.messageText = "Allow this command?"
|
|
||||||
|
|
||||||
var details = "Clawdbot wants to run:\n\n\(context.command)"
|
|
||||||
let trimmedCwd = context.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedCwd.isEmpty {
|
|
||||||
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
|
||||||
}
|
|
||||||
let trimmedAgent = context.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedAgent.isEmpty {
|
|
||||||
details += "\n\nAgent:\n\(trimmedAgent)"
|
|
||||||
}
|
|
||||||
let trimmedPath = context.executablePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedPath.isEmpty {
|
|
||||||
details += "\n\nExecutable:\n\(trimmedPath)"
|
|
||||||
}
|
|
||||||
details += "\n\nThis runs on this Mac via node mode."
|
|
||||||
alert.informativeText = details
|
|
||||||
|
|
||||||
alert.addButton(withTitle: "Allow Once")
|
|
||||||
alert.addButton(withTitle: "Always Allow")
|
|
||||||
alert.addButton(withTitle: "Don't Allow")
|
|
||||||
|
|
||||||
switch alert.runModal() {
|
|
||||||
case .alertFirstButtonReturn:
|
|
||||||
return .allowOnce
|
|
||||||
case .alertSecondButtonReturn:
|
|
||||||
return .allowAlways
|
|
||||||
default:
|
|
||||||
return .deny
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum SystemRunAllowlistMatchKind: String {
|
|
||||||
case glob
|
|
||||||
case argv
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SystemRunAllowlistSource: String {
|
|
||||||
case manual
|
|
||||||
case skill
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SystemRunAllowlistEntry: Identifiable, Hashable {
|
|
||||||
let id: String
|
|
||||||
var pattern: String
|
|
||||||
var enabled: Bool
|
|
||||||
var matchKind: SystemRunAllowlistMatchKind
|
|
||||||
var source: SystemRunAllowlistSource?
|
|
||||||
var skillId: String?
|
|
||||||
var lastUsedAt: Date?
|
|
||||||
var lastUsedCommand: String?
|
|
||||||
var lastUsedPath: String?
|
|
||||||
|
|
||||||
init(
|
|
||||||
id: String = UUID().uuidString,
|
|
||||||
pattern: String,
|
|
||||||
enabled: Bool = true,
|
|
||||||
matchKind: SystemRunAllowlistMatchKind = .glob,
|
|
||||||
source: SystemRunAllowlistSource? = .manual,
|
|
||||||
skillId: String? = nil,
|
|
||||||
lastUsedAt: Date? = nil,
|
|
||||||
lastUsedCommand: String? = nil,
|
|
||||||
lastUsedPath: String? = nil)
|
|
||||||
{
|
|
||||||
self.id = id
|
|
||||||
self.pattern = pattern
|
|
||||||
self.enabled = enabled
|
|
||||||
self.matchKind = matchKind
|
|
||||||
self.source = source
|
|
||||||
self.skillId = skillId
|
|
||||||
self.lastUsedAt = lastUsedAt
|
|
||||||
self.lastUsedCommand = lastUsedCommand
|
|
||||||
self.lastUsedPath = lastUsedPath
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(dict: [String: Any]) {
|
|
||||||
let id = dict["id"] as? String ?? UUID().uuidString
|
|
||||||
let pattern = (dict["pattern"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if pattern.isEmpty { return nil }
|
|
||||||
let enabled = dict["enabled"] as? Bool ?? true
|
|
||||||
let matchRaw = dict["matchKind"] as? String
|
|
||||||
let matchKind = SystemRunAllowlistMatchKind(rawValue: matchRaw ?? "") ?? .glob
|
|
||||||
let sourceRaw = dict["source"] as? String
|
|
||||||
let source = SystemRunAllowlistSource(rawValue: sourceRaw ?? "")
|
|
||||||
let skillId = (dict["skillId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let lastUsedAt = (dict["lastUsedAt"] as? Double).map { Date(timeIntervalSince1970: $0) }
|
|
||||||
let lastUsedCommand = dict["lastUsedCommand"] as? String
|
|
||||||
let lastUsedPath = dict["lastUsedPath"] as? String
|
|
||||||
|
|
||||||
self.init(
|
|
||||||
id: id,
|
|
||||||
pattern: pattern,
|
|
||||||
enabled: enabled,
|
|
||||||
matchKind: matchKind,
|
|
||||||
source: source,
|
|
||||||
skillId: skillId?.isEmpty == true ? nil : skillId,
|
|
||||||
lastUsedAt: lastUsedAt,
|
|
||||||
lastUsedCommand: lastUsedCommand,
|
|
||||||
lastUsedPath: lastUsedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func asDict() -> [String: Any] {
|
|
||||||
var dict: [String: Any] = [
|
|
||||||
"id": self.id,
|
|
||||||
"pattern": self.pattern,
|
|
||||||
"enabled": self.enabled,
|
|
||||||
"matchKind": self.matchKind.rawValue,
|
|
||||||
]
|
|
||||||
if let source = self.source { dict["source"] = source.rawValue }
|
|
||||||
if let skillId = self.skillId { dict["skillId"] = skillId }
|
|
||||||
if let lastUsedAt = self.lastUsedAt { dict["lastUsedAt"] = lastUsedAt.timeIntervalSince1970 }
|
|
||||||
if let lastUsedCommand = self.lastUsedCommand { dict["lastUsedCommand"] = lastUsedCommand }
|
|
||||||
if let lastUsedPath = self.lastUsedPath { dict["lastUsedPath"] = lastUsedPath }
|
|
||||||
return dict
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SystemRunCommandResolution: Sendable {
|
|
||||||
let rawExecutable: String
|
|
||||||
let resolvedPath: String?
|
|
||||||
let executableName: String
|
|
||||||
let cwd: String?
|
|
||||||
|
|
||||||
static func resolve(command: [String], cwd: String?) -> SystemRunCommandResolution? {
|
|
||||||
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw
|
|
||||||
let hasPathSeparator = expanded.contains("/")
|
|
||||||
let resolvedPath: String? = {
|
|
||||||
if hasPathSeparator {
|
|
||||||
if expanded.hasPrefix("/") {
|
|
||||||
return expanded
|
|
||||||
}
|
|
||||||
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath
|
|
||||||
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
|
|
||||||
}
|
|
||||||
return CommandResolver.findExecutable(named: expanded)
|
|
||||||
}()
|
|
||||||
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
|
|
||||||
return SystemRunCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SystemRunAllowlistStore {
|
|
||||||
static func load(agentId: String?) -> [SystemRunAllowlistEntry] {
|
|
||||||
if let entries = MacNodeConfigFile.systemRunAllowlist(agentId: agentId) {
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
static func save(_ entries: [SystemRunAllowlistEntry], agentId: String?) {
|
|
||||||
MacNodeConfigFile.setSystemRunAllowlist(entries, agentId: agentId)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func add(pattern: String, agentId: String?, source: SystemRunAllowlistSource = .manual) -> SystemRunAllowlistEntry {
|
|
||||||
var entries = self.load(agentId: agentId)
|
|
||||||
let entry = SystemRunAllowlistEntry(pattern: pattern, enabled: true, matchKind: .glob, source: source)
|
|
||||||
entries.append(entry)
|
|
||||||
self.save(entries, agentId: agentId)
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
static func update(_ entry: SystemRunAllowlistEntry, agentId: String?) {
|
|
||||||
var entries = self.load(agentId: agentId)
|
|
||||||
guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return }
|
|
||||||
entries[index] = entry
|
|
||||||
self.save(entries, agentId: agentId)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func remove(entryId: String, agentId: String?) {
|
|
||||||
let entries = self.load(agentId: agentId).filter { $0.id != entryId }
|
|
||||||
self.save(entries, agentId: agentId)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func markUsed(entryId: String, command: [String], resolvedPath: String?, agentId: String?) {
|
|
||||||
var entries = self.load(agentId: agentId)
|
|
||||||
guard let index = entries.firstIndex(where: { $0.id == entryId }) else { return }
|
|
||||||
entries[index].lastUsedAt = Date()
|
|
||||||
entries[index].lastUsedCommand = SystemRunAllowlist.displayString(for: command)
|
|
||||||
entries[index].lastUsedPath = resolvedPath
|
|
||||||
self.save(entries, agentId: agentId)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func match(
|
|
||||||
command: [String],
|
|
||||||
resolution: SystemRunCommandResolution?,
|
|
||||||
entries: [SystemRunAllowlistEntry]) -> SystemRunAllowlistEntry?
|
|
||||||
{
|
|
||||||
guard !entries.isEmpty else { return nil }
|
|
||||||
let argvKey = SystemRunAllowlist.legacyKey(for: command)
|
|
||||||
let resolvedPath = resolution?.resolvedPath
|
|
||||||
let executableName = resolution?.executableName
|
|
||||||
let rawExecutable = resolution?.rawExecutable
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
guard entry.enabled else { continue }
|
|
||||||
switch entry.matchKind {
|
|
||||||
case .argv:
|
|
||||||
if argvKey == entry.pattern { return entry }
|
|
||||||
case .glob:
|
|
||||||
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if pattern.isEmpty { continue }
|
|
||||||
let hasPath = pattern.contains("/") || pattern.contains("~")
|
|
||||||
if hasPath {
|
|
||||||
let target = resolvedPath ?? rawExecutable
|
|
||||||
if let target, SystemRunGlob.matches(pattern: pattern, target: target) {
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
} else if let name = executableName, SystemRunGlob.matches(pattern: pattern, target: name) {
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SystemRunGlob {
|
|
||||||
static func matches(pattern rawPattern: String, target: String) -> Bool {
|
|
||||||
let trimmed = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return false }
|
|
||||||
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
|
|
||||||
guard let regex = self.regex(for: expanded) else { return false }
|
|
||||||
let range = NSRange(location: 0, length: target.utf16.count)
|
|
||||||
return regex.firstMatch(in: target, options: [], range: range) != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func regex(for pattern: String) -> NSRegularExpression? {
|
|
||||||
var regex = "^"
|
|
||||||
var idx = pattern.startIndex
|
|
||||||
while idx < pattern.endIndex {
|
|
||||||
let ch = pattern[idx]
|
|
||||||
if ch == "*" {
|
|
||||||
let next = pattern.index(after: idx)
|
|
||||||
if next < pattern.endIndex, pattern[next] == "*" {
|
|
||||||
regex += ".*"
|
|
||||||
idx = pattern.index(after: next)
|
|
||||||
} else {
|
|
||||||
regex += "[^/]*"
|
|
||||||
idx = next
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ch == "?" {
|
|
||||||
regex += "."
|
|
||||||
idx = pattern.index(after: idx)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
regex += NSRegularExpression.escapedPattern(for: String(ch))
|
|
||||||
idx = pattern.index(after: idx)
|
|
||||||
}
|
|
||||||
regex += "$"
|
|
||||||
return try? NSRegularExpression(pattern: regex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actor SkillBinsCache {
|
|
||||||
static let shared = SkillBinsCache()
|
|
||||||
|
|
||||||
private var bins: Set<String> = []
|
|
||||||
private var lastRefresh: Date?
|
|
||||||
private let refreshInterval: TimeInterval = 90
|
|
||||||
|
|
||||||
func currentBins(force: Bool = false) async -> Set<String> {
|
|
||||||
if force || self.isStale() {
|
|
||||||
await self.refresh()
|
|
||||||
}
|
|
||||||
return self.bins
|
|
||||||
}
|
|
||||||
|
|
||||||
func refresh() async {
|
|
||||||
do {
|
|
||||||
let report = try await GatewayConnection.shared.skillsStatus()
|
|
||||||
var next = Set<String>()
|
|
||||||
for skill in report.skills {
|
|
||||||
for bin in skill.requirements.bins {
|
|
||||||
let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !trimmed.isEmpty { next.insert(trimmed) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.bins = next
|
|
||||||
self.lastRefresh = Date()
|
|
||||||
} catch {
|
|
||||||
if self.lastRefresh == nil {
|
|
||||||
self.bins = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isStale() -> Bool {
|
|
||||||
guard let lastRefresh else { return true }
|
|
||||||
return Date().timeIntervalSince(lastRefresh) > self.refreshInterval
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
|
||||||
case never
|
|
||||||
case ask
|
|
||||||
case always
|
|
||||||
|
|
||||||
var id: String { self.rawValue }
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .never:
|
|
||||||
"Never"
|
|
||||||
case .ask:
|
|
||||||
"Always Ask"
|
|
||||||
case .always:
|
|
||||||
"Always Allow"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func load(agentId: String? = nil, from defaults: UserDefaults = .standard) -> SystemRunPolicy {
|
|
||||||
if let policy = MacNodeConfigFile.systemRunPolicy(agentId: agentId) {
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
if let policy = MacNodeConfigFile.systemRunPolicy() {
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
if let raw = defaults.string(forKey: systemRunPolicyKey),
|
|
||||||
let policy = SystemRunPolicy(rawValue: raw)
|
|
||||||
{
|
|
||||||
MacNodeConfigFile.setSystemRunPolicy(policy)
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
if let legacy = defaults.object(forKey: systemRunEnabledKey) as? Bool {
|
|
||||||
let policy: SystemRunPolicy = legacy ? .ask : .never
|
|
||||||
MacNodeConfigFile.setSystemRunPolicy(policy)
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
let fallback: SystemRunPolicy = .ask
|
|
||||||
MacNodeConfigFile.setSystemRunPolicy(fallback)
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SystemRunAllowlist {
|
|
||||||
static func legacyKey(for argv: [String]) -> String {
|
|
||||||
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
guard !trimmed.isEmpty else { return "" }
|
|
||||||
if let data = try? JSONEncoder().encode(trimmed),
|
|
||||||
let json = String(data: data, encoding: .utf8)
|
|
||||||
{
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
return trimmed.joined(separator: " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func displayString(for argv: [String]) -> String {
|
|
||||||
argv.map { arg in
|
|
||||||
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return "\"\"" }
|
|
||||||
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
|
|
||||||
if !needsQuotes { return trimmed }
|
|
||||||
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
|
|
||||||
return "\"\(escaped)\""
|
|
||||||
}.joined(separator: " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func loadLegacy(from defaults: UserDefaults = .standard) -> Set<String> {
|
|
||||||
if let allowlist = MacNodeConfigFile.systemRunAllowlistStrings() {
|
|
||||||
return Set(allowlist)
|
|
||||||
}
|
|
||||||
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
|
|
||||||
MacNodeConfigFile.setSystemRunAllowlistStrings(legacy)
|
|
||||||
return Set(legacy)
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,14 @@ import Observation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SystemRunSettingsView: View {
|
struct SystemRunSettingsView: View {
|
||||||
@State private var model = SystemRunSettingsModel()
|
@State private var model = ExecApprovalsSettingsModel()
|
||||||
@State private var tab: SystemRunSettingsTab = .policy
|
@State private var tab: ExecApprovalsSettingsTab = .policy
|
||||||
@State private var newPattern: String = ""
|
@State private var newPattern: String = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack(alignment: .center, spacing: 12) {
|
HStack(alignment: .center, spacing: 12) {
|
||||||
Text("Node Run Commands")
|
Text("Exec approvals")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
if self.model.agentIds.count > 1 {
|
if self.model.agentIds.count > 1 {
|
||||||
@@ -28,12 +28,12 @@ struct SystemRunSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Picker("", selection: self.$tab) {
|
Picker("", selection: self.$tab) {
|
||||||
ForEach(SystemRunSettingsTab.allCases) { tab in
|
ForEach(ExecApprovalsSettingsTab.allCases) { tab in
|
||||||
Text(tab.title).tag(tab)
|
Text(tab.title).tag(tab)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.frame(width: 280)
|
.frame(width: 320)
|
||||||
|
|
||||||
if self.tab == .policy {
|
if self.tab == .policy {
|
||||||
self.policyView
|
self.policyView
|
||||||
@@ -48,19 +48,41 @@ struct SystemRunSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var policyView: some View {
|
private var policyView: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Picker("", selection: Binding(
|
Picker("", selection: Binding(
|
||||||
get: { self.model.policy },
|
get: { self.model.security },
|
||||||
set: { self.model.setPolicy($0) }))
|
set: { self.model.setSecurity($0) }))
|
||||||
{
|
{
|
||||||
ForEach(SystemRunPolicy.allCases) { policy in
|
ForEach(ExecSecurity.allCases) { security in
|
||||||
Text(policy.title).tag(policy)
|
Text(security.title).tag(security)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
Text("Controls remote command execution on this Mac when it is paired as a node. \"Always Ask\" prompts on each command; \"Always Allow\" runs without prompts; \"Never\" disables system.run.")
|
Picker("", selection: Binding(
|
||||||
|
get: { self.model.ask },
|
||||||
|
set: { self.model.setAsk($0) }))
|
||||||
|
{
|
||||||
|
ForEach(ExecAsk.allCases) { ask in
|
||||||
|
Text(ask.title).tag(ask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
Picker("", selection: Binding(
|
||||||
|
get: { self.model.askFallback },
|
||||||
|
set: { self.model.setAskFallback($0) }))
|
||||||
|
{
|
||||||
|
ForEach(ExecSecurity.allCases) { mode in
|
||||||
|
Text("Fallback: \(mode.title)").tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
Text("Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -80,7 +102,7 @@ struct SystemRunSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
TextField("Add allowlist pattern (supports globs)", text: self.$newPattern)
|
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
Button("Add") {
|
Button("Add") {
|
||||||
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -98,12 +120,12 @@ struct SystemRunSettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(Array(self.model.entries.enumerated()), id: \.element.id) { index, _ in
|
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
||||||
SystemRunAllowlistRow(
|
ExecAllowlistRow(
|
||||||
entry: Binding(
|
entry: Binding(
|
||||||
get: { self.model.entries[index] },
|
get: { self.model.entries[index] },
|
||||||
set: { self.model.updateEntry($0) }),
|
set: { self.model.updateEntry($0, at: index) }),
|
||||||
onRemove: { self.model.removeEntry($0.id) })
|
onRemove: { self.model.removeEntry(at: index) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +133,7 @@ struct SystemRunSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum SystemRunSettingsTab: String, CaseIterable, Identifiable {
|
private enum ExecApprovalsSettingsTab: String, CaseIterable, Identifiable {
|
||||||
case policy
|
case policy
|
||||||
case allowlist
|
case allowlist
|
||||||
|
|
||||||
@@ -119,15 +141,15 @@ private enum SystemRunSettingsTab: String, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .policy: "Policy"
|
case .policy: "Access"
|
||||||
case .allowlist: "Allowlist"
|
case .allowlist: "Allowlist"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SystemRunAllowlistRow: View {
|
struct ExecAllowlistRow: View {
|
||||||
@Binding var entry: SystemRunAllowlistEntry
|
@Binding var entry: ExecAllowlistEntry
|
||||||
let onRemove: (SystemRunAllowlistEntry) -> Void
|
let onRemove: () -> Void
|
||||||
@State private var draftPattern: String = ""
|
@State private var draftPattern: String = ""
|
||||||
|
|
||||||
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||||
@@ -139,20 +161,11 @@ struct SystemRunAllowlistRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Toggle("", isOn: self.$entry.enabled)
|
|
||||||
.labelsHidden()
|
|
||||||
|
|
||||||
TextField("Pattern", text: self.patternBinding)
|
TextField("Pattern", text: self.patternBinding)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
||||||
if self.entry.matchKind == .argv {
|
|
||||||
Text("Legacy")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
self.onRemove(self.entry)
|
self.onRemove()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "trash")
|
Image(systemName: "trash")
|
||||||
}
|
}
|
||||||
@@ -160,7 +173,8 @@ struct SystemRunAllowlistRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let lastUsedAt = self.entry.lastUsedAt {
|
if let lastUsedAt = self.entry.lastUsedAt {
|
||||||
Text("Last used \(Self.relativeFormatter.localizedString(for: lastUsedAt, relativeTo: Date()))")
|
let date = Date(timeIntervalSince1970: lastUsedAt / 1000.0)
|
||||||
|
Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
|
} else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
|
||||||
@@ -180,22 +194,21 @@ struct SystemRunAllowlistRow: View {
|
|||||||
set: { newValue in
|
set: { newValue in
|
||||||
self.draftPattern = newValue
|
self.draftPattern = newValue
|
||||||
self.entry.pattern = newValue
|
self.entry.pattern = newValue
|
||||||
if self.entry.matchKind == .argv {
|
|
||||||
self.entry.matchKind = .glob
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class SystemRunSettingsModel {
|
final class ExecApprovalsSettingsModel {
|
||||||
var agentIds: [String] = []
|
var agentIds: [String] = []
|
||||||
var selectedAgentId: String = "main"
|
var selectedAgentId: String = "main"
|
||||||
var defaultAgentId: String = "main"
|
var defaultAgentId: String = "main"
|
||||||
var policy: SystemRunPolicy = .ask
|
var security: ExecSecurity = .deny
|
||||||
|
var ask: ExecAsk = .onMiss
|
||||||
|
var askFallback: ExecSecurity = .deny
|
||||||
var autoAllowSkills = false
|
var autoAllowSkills = false
|
||||||
var entries: [SystemRunAllowlistEntry] = []
|
var entries: [ExecAllowlistEntry] = []
|
||||||
var skillBins: [String] = []
|
var skillBins: [String] = []
|
||||||
|
|
||||||
func refresh() async {
|
func refresh() async {
|
||||||
@@ -241,43 +254,63 @@ final class SystemRunSettingsModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadSettings(for agentId: String) {
|
func loadSettings(for agentId: String) {
|
||||||
self.policy = SystemRunPolicy.load(agentId: agentId)
|
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
|
||||||
self.autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false
|
self.security = resolved.agent.security
|
||||||
self.entries = SystemRunAllowlistStore.load(agentId: agentId)
|
self.ask = resolved.agent.ask
|
||||||
|
self.askFallback = resolved.agent.askFallback
|
||||||
|
self.autoAllowSkills = resolved.agent.autoAllowSkills
|
||||||
|
self.entries = resolved.allowlist
|
||||||
.sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending }
|
.sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending }
|
||||||
}
|
}
|
||||||
|
|
||||||
func setPolicy(_ policy: SystemRunPolicy) {
|
func setSecurity(_ security: ExecSecurity) {
|
||||||
self.policy = policy
|
self.security = security
|
||||||
MacNodeConfigFile.setSystemRunPolicy(policy, agentId: self.selectedAgentId)
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
|
entry.security = security
|
||||||
AppStateStore.shared.systemRunPolicy = policy
|
}
|
||||||
|
self.syncQuickMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAsk(_ ask: ExecAsk) {
|
||||||
|
self.ask = ask
|
||||||
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
|
entry.ask = ask
|
||||||
|
}
|
||||||
|
self.syncQuickMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAskFallback(_ mode: ExecSecurity) {
|
||||||
|
self.askFallback = mode
|
||||||
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
|
entry.askFallback = mode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setAutoAllowSkills(_ enabled: Bool) {
|
func setAutoAllowSkills(_ enabled: Bool) {
|
||||||
self.autoAllowSkills = enabled
|
self.autoAllowSkills = enabled
|
||||||
MacNodeConfigFile.setSystemRunAutoAllowSkills(enabled, agentId: self.selectedAgentId)
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
|
entry.autoAllowSkills = enabled
|
||||||
|
}
|
||||||
Task { await self.refreshSkillBins(force: enabled) }
|
Task { await self.refreshSkillBins(force: enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func addEntry(_ pattern: String) {
|
func addEntry(_ pattern: String) {
|
||||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
let entry = SystemRunAllowlistEntry(pattern: trimmed, enabled: true, matchKind: .glob, source: .manual)
|
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
|
||||||
self.entries.append(entry)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateEntry(_ entry: SystemRunAllowlistEntry) {
|
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
|
||||||
guard let index = self.entries.firstIndex(where: { $0.id == entry.id }) else { return }
|
guard self.entries.indices.contains(index) else { return }
|
||||||
self.entries[index] = entry
|
self.entries[index] = entry
|
||||||
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeEntry(_ id: String) {
|
func removeEntry(at index: Int) {
|
||||||
self.entries.removeAll { $0.id == id }
|
guard self.entries.indices.contains(index) else { return }
|
||||||
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
|
self.entries.remove(at: index)
|
||||||
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshSkillBins(force: Bool = false) async {
|
func refreshSkillBins(force: Bool = false) async {
|
||||||
@@ -288,4 +321,10 @@ final class SystemRunSettingsModel {
|
|||||||
let bins = await SkillBinsCache.shared.currentBins(force: force)
|
let bins = await SkillBinsCache.shared.currentBins(force: force)
|
||||||
self.skillBins = bins.sorted()
|
self.skillBins = bins.sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func syncQuickMode() {
|
||||||
|
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
|
||||||
|
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -760,6 +760,10 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
public let reasoninglevel: AnyCodable?
|
public let reasoninglevel: AnyCodable?
|
||||||
public let responseusage: AnyCodable?
|
public let responseusage: AnyCodable?
|
||||||
public let elevatedlevel: AnyCodable?
|
public let elevatedlevel: AnyCodable?
|
||||||
|
public let exechost: AnyCodable?
|
||||||
|
public let execsecurity: AnyCodable?
|
||||||
|
public let execask: AnyCodable?
|
||||||
|
public let execnode: AnyCodable?
|
||||||
public let model: AnyCodable?
|
public let model: AnyCodable?
|
||||||
public let spawnedby: AnyCodable?
|
public let spawnedby: AnyCodable?
|
||||||
public let sendpolicy: AnyCodable?
|
public let sendpolicy: AnyCodable?
|
||||||
@@ -773,6 +777,10 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
reasoninglevel: AnyCodable?,
|
reasoninglevel: AnyCodable?,
|
||||||
responseusage: AnyCodable?,
|
responseusage: AnyCodable?,
|
||||||
elevatedlevel: AnyCodable?,
|
elevatedlevel: AnyCodable?,
|
||||||
|
exechost: AnyCodable?,
|
||||||
|
execsecurity: AnyCodable?,
|
||||||
|
execask: AnyCodable?,
|
||||||
|
execnode: AnyCodable?,
|
||||||
model: AnyCodable?,
|
model: AnyCodable?,
|
||||||
spawnedby: AnyCodable?,
|
spawnedby: AnyCodable?,
|
||||||
sendpolicy: AnyCodable?,
|
sendpolicy: AnyCodable?,
|
||||||
@@ -785,6 +793,10 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
self.reasoninglevel = reasoninglevel
|
self.reasoninglevel = reasoninglevel
|
||||||
self.responseusage = responseusage
|
self.responseusage = responseusage
|
||||||
self.elevatedlevel = elevatedlevel
|
self.elevatedlevel = elevatedlevel
|
||||||
|
self.exechost = exechost
|
||||||
|
self.execsecurity = execsecurity
|
||||||
|
self.execask = execask
|
||||||
|
self.execnode = execnode
|
||||||
self.model = model
|
self.model = model
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
self.sendpolicy = sendpolicy
|
self.sendpolicy = sendpolicy
|
||||||
@@ -798,6 +810,10 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
case reasoninglevel = "reasoningLevel"
|
case reasoninglevel = "reasoningLevel"
|
||||||
case responseusage = "responseUsage"
|
case responseusage = "responseUsage"
|
||||||
case elevatedlevel = "elevatedLevel"
|
case elevatedlevel = "elevatedLevel"
|
||||||
|
case exechost = "execHost"
|
||||||
|
case execsecurity = "execSecurity"
|
||||||
|
case execask = "execAsk"
|
||||||
|
case execnode = "execNode"
|
||||||
case model
|
case model
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
case sendpolicy = "sendPolicy"
|
case sendpolicy = "sendPolicy"
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
struct ExecAllowlistTests {
|
||||||
|
@Test func matchUsesResolvedPath() {
|
||||||
|
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
|
||||||
|
let resolution = ExecCommandResolution(
|
||||||
|
rawExecutable: "rg",
|
||||||
|
resolvedPath: "/opt/homebrew/bin/rg",
|
||||||
|
executableName: "rg",
|
||||||
|
cwd: nil)
|
||||||
|
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||||
|
#expect(match?.pattern == entry.pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func matchUsesBasenameForSimplePattern() {
|
||||||
|
let entry = ExecAllowlistEntry(pattern: "rg")
|
||||||
|
let resolution = ExecCommandResolution(
|
||||||
|
rawExecutable: "rg",
|
||||||
|
resolvedPath: "/opt/homebrew/bin/rg",
|
||||||
|
executableName: "rg",
|
||||||
|
cwd: nil)
|
||||||
|
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||||
|
#expect(match?.pattern == entry.pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func matchIsCaseInsensitive() {
|
||||||
|
let entry = ExecAllowlistEntry(pattern: "RG")
|
||||||
|
let resolution = ExecCommandResolution(
|
||||||
|
rawExecutable: "rg",
|
||||||
|
resolvedPath: "/opt/homebrew/bin/rg",
|
||||||
|
executableName: "rg",
|
||||||
|
cwd: nil)
|
||||||
|
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||||
|
#expect(match?.pattern == entry.pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func matchSupportsGlobStar() {
|
||||||
|
let entry = ExecAllowlistEntry(pattern: "/opt/**/rg")
|
||||||
|
let resolution = ExecCommandResolution(
|
||||||
|
rawExecutable: "rg",
|
||||||
|
resolvedPath: "/opt/homebrew/bin/rg",
|
||||||
|
executableName: "rg",
|
||||||
|
cwd: nil)
|
||||||
|
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||||
|
#expect(match?.pattern == entry.pattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,10 +74,6 @@ struct MacNodeRuntimeTests {
|
|||||||
{
|
{
|
||||||
CLLocation(latitude: 0, longitude: 0)
|
CLLocation(latitude: 0, longitude: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision {
|
|
||||||
.allowOnce
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let services = await MainActor.run { FakeMainActorServices() }
|
let services = await MainActor.run { FakeMainActorServices() }
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import Clawdbot
|
|
||||||
|
|
||||||
struct SystemRunAllowlistTests {
|
|
||||||
@Test func matchUsesResolvedPath() {
|
|
||||||
let entry = SystemRunAllowlistEntry(pattern: "/opt/homebrew/bin/rg", enabled: true, matchKind: .glob)
|
|
||||||
let resolution = SystemRunCommandResolution(
|
|
||||||
rawExecutable: "rg",
|
|
||||||
resolvedPath: "/opt/homebrew/bin/rg",
|
|
||||||
executableName: "rg",
|
|
||||||
cwd: nil)
|
|
||||||
let match = SystemRunAllowlistStore.match(
|
|
||||||
command: ["rg"],
|
|
||||||
resolution: resolution,
|
|
||||||
entries: [entry])
|
|
||||||
#expect(match?.id == entry.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func matchUsesBasenameForSimplePattern() {
|
|
||||||
let entry = SystemRunAllowlistEntry(pattern: "rg", enabled: true, matchKind: .glob)
|
|
||||||
let resolution = SystemRunCommandResolution(
|
|
||||||
rawExecutable: "rg",
|
|
||||||
resolvedPath: "/opt/homebrew/bin/rg",
|
|
||||||
executableName: "rg",
|
|
||||||
cwd: nil)
|
|
||||||
let match = SystemRunAllowlistStore.match(
|
|
||||||
command: ["rg"],
|
|
||||||
resolution: resolution,
|
|
||||||
entries: [entry])
|
|
||||||
#expect(match?.id == entry.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func matchUsesLegacyArgvKey() {
|
|
||||||
let key = SystemRunAllowlist.legacyKey(for: ["echo", "hi"])
|
|
||||||
let entry = SystemRunAllowlistEntry(pattern: key, enabled: true, matchKind: .argv)
|
|
||||||
let match = SystemRunAllowlistStore.match(
|
|
||||||
command: ["echo", "hi"],
|
|
||||||
resolution: nil,
|
|
||||||
entries: [entry])
|
|
||||||
#expect(match?.id == entry.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,6 +25,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
public var timeoutMs: Int?
|
public var timeoutMs: Int?
|
||||||
public var needsScreenRecording: Bool?
|
public var needsScreenRecording: Bool?
|
||||||
public var agentId: String?
|
public var agentId: String?
|
||||||
|
public var sessionKey: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
command: [String],
|
command: [String],
|
||||||
@@ -32,7 +33,8 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
env: [String: String]? = nil,
|
env: [String: String]? = nil,
|
||||||
timeoutMs: Int? = nil,
|
timeoutMs: Int? = nil,
|
||||||
needsScreenRecording: Bool? = nil,
|
needsScreenRecording: Bool? = nil,
|
||||||
agentId: String? = nil)
|
agentId: String? = nil,
|
||||||
|
sessionKey: String? = nil)
|
||||||
{
|
{
|
||||||
self.command = command
|
self.command = command
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
@@ -40,6 +42,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
self.timeoutMs = timeoutMs
|
self.timeoutMs = timeoutMs
|
||||||
self.needsScreenRecording = needsScreenRecording
|
self.needsScreenRecording = needsScreenRecording
|
||||||
self.agentId = agentId
|
self.agentId = agentId
|
||||||
|
self.sessionKey = sessionKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing)."
|
||||||
|
read_when:
|
||||||
|
- Setting up BlueBubbles channel
|
||||||
|
- Troubleshooting webhook pairing
|
||||||
|
---
|
||||||
|
# BlueBubbles (macOS REST)
|
||||||
|
|
||||||
|
Status: bundled plugin (disabled by default) that talks to the BlueBubbles macOS server over HTTP.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
- Runs on macOS via the BlueBubbles helper app (`https://bluebubbles.app`).
|
||||||
|
- Clawdbot talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`).
|
||||||
|
- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls.
|
||||||
|
- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible).
|
||||||
|
- Pairing/allowlist works the same way as other channels (`/start/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes.
|
||||||
|
- Reactions are surfaced as system events just like Slack/Telegram so agents can “mention” them before replying.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
1. Install the BlueBubbles server on your Mac (follows the app store instructions at `https://bluebubbles.app/install`).
|
||||||
|
2. In the BlueBubbles config, enable the web API and set a password for `guid`/`password`.
|
||||||
|
3. Configure Clawdbot:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
bluebubbles: {
|
||||||
|
enabled: true,
|
||||||
|
serverUrl: "http://bluebubbles-host:1234",
|
||||||
|
password: "example-password",
|
||||||
|
webhookPath: "/bluebubbles-webhook",
|
||||||
|
actions: { reactions: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. Point BlueBubbles webhooks to your gateway (example: `http://your-gateway-host/bluebubbles-webhook?password=<password>`).
|
||||||
|
5. Start the gateway; it will register the webhook handler and start pairing.
|
||||||
|
|
||||||
|
## Configuration notes
|
||||||
|
- `channels.bluebubbles.serverUrl`: base URL of the BlueBubbles REST API.
|
||||||
|
- `channels.bluebubbles.password`: password that BlueBubbles expects on every request (`?password=...` or header).
|
||||||
|
- `channels.bluebubbles.webhookPath`: HTTP path the gateway exposes for BlueBubbles webhooks.
|
||||||
|
- `channels.bluebubbles.dmPolicy` / `groupPolicy` + `allowFrom`/`groupAllowFrom` behave like other channels; pairing/allowlist info is stored in `/pairing`.
|
||||||
|
- `channels.bluebubbles.actions.reactions` toggles whether the gateway enqueues system events for reactions/tapbacks.
|
||||||
|
- `channels.bluebubbles.textChunkLimit` overrides the default 4k limit.
|
||||||
|
- `channels.bluebubbles.mediaMaxMb` controls the max size of inbound attachments saved for analysis (default 8MB).
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
- Outbound replies: `sendMessageBlueBubbles` resolves a chat GUID via `/api/v1/chat/query` and posts to `/api/v1/message/text`. Typing (`/api/v1/chat/<guid>/typing`) and read receipts (`/api/v1/chat/<guid>/read`) are sent before/after responses.
|
||||||
|
- Webhooks: BlueBubbles POSTs JSON payloads with `type` and `data`. The plugin ignores non-message events (typing indicator, read status) and extracts `chatGuid` from `data.chats[0].guid`.
|
||||||
|
- Reactions/tapbacks generate `BlueBubbles reaction added/removed` system events so agents can mention them. Agents can also trigger tapbacks via the `react` action with `messageId`, `emoji`, and a `to`/`chatGuid`.
|
||||||
|
- Attachments are downloaded via the REST API and stored in the inbound media cache; text-less messages are converted into `<media:...>` placeholders so the agent knows something was sent.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
||||||
|
- Keep the API password and webhook endpoint secret (treat them like credentials).
|
||||||
|
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- If Voice/typing events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`.
|
||||||
|
- Pairing codes expire after one hour; use `clawdbot pairing list bluebubbles` and `clawdbot pairing approve bluebubbles <code>`.
|
||||||
|
- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it.
|
||||||
|
|
||||||
|
For general channel workflow reference, see [/channels/index] and the [[plugins|/plugin]] guide.
|
||||||
@@ -17,6 +17,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
|||||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||||
- [iMessage](/channels/imessage) — macOS only; native integration.
|
- [iMessage](/channels/imessage) — macOS only; native integration.
|
||||||
|
- [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default).
|
||||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||||
|
|||||||
+18
-1
@@ -11,6 +11,7 @@ Manage agent hooks (event-driven automations for commands like `/new`, `/reset`,
|
|||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Hooks: [Hooks](/hooks)
|
- Hooks: [Hooks](/hooks)
|
||||||
|
- Plugin hooks: [Plugins](/plugin#plugin-hooks)
|
||||||
|
|
||||||
## List All Hooks
|
## List All Hooks
|
||||||
|
|
||||||
@@ -28,11 +29,12 @@ List all discovered hooks from workspace, managed, and bundled directories.
|
|||||||
**Example output:**
|
**Example output:**
|
||||||
|
|
||||||
```
|
```
|
||||||
Hooks (2/2 ready)
|
Hooks (3/3 ready)
|
||||||
|
|
||||||
Ready:
|
Ready:
|
||||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
📝 command-logger ✓ - Log all command events to a centralized audit file
|
||||||
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
||||||
|
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example (verbose):**
|
**Example (verbose):**
|
||||||
@@ -118,6 +120,9 @@ clawdbot hooks enable <name>
|
|||||||
|
|
||||||
Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
|
Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
|
||||||
|
|
||||||
|
**Note:** Hooks managed by plugins show `plugin:<id>` in `clawdbot hooks list` and
|
||||||
|
can’t be enabled/disabled here. Enable/disable the plugin instead.
|
||||||
|
|
||||||
**Arguments:**
|
**Arguments:**
|
||||||
- `<name>`: Hook name (e.g., `session-memory`)
|
- `<name>`: Hook name (e.g., `session-memory`)
|
||||||
|
|
||||||
@@ -256,3 +261,15 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
|
|||||||
```
|
```
|
||||||
|
|
||||||
**See:** [command-logger documentation](/hooks#command-logger)
|
**See:** [command-logger documentation](/hooks#command-logger)
|
||||||
|
|
||||||
|
### soul-evil
|
||||||
|
|
||||||
|
Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance.
|
||||||
|
|
||||||
|
**Enable:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot hooks enable soul-evil
|
||||||
|
```
|
||||||
|
|
||||||
|
**See:** [SOUL Evil Hook](/hooks/soul-evil)
|
||||||
+1
-1
@@ -522,7 +522,7 @@ Options:
|
|||||||
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
||||||
|
|
||||||
Surfaces:
|
Surfaces:
|
||||||
- `/status` (alias: `/usage`; adds a short usage line when available)
|
- `/status` (adds a short provider usage line when available)
|
||||||
- `clawdbot status --usage` (prints full provider breakdown)
|
- `clawdbot status --usage` (prints full provider breakdown)
|
||||||
- macOS menu bar (Usage section under Context)
|
- macOS menu bar (Usage section under Context)
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Context is *not the same thing* as “memory”: memory can be stored on disk an
|
|||||||
- `/status` → quick “how full is my window?” view + session settings.
|
- `/status` → quick “how full is my window?” view + session settings.
|
||||||
- `/context list` → what’s injected + rough sizes (per file + totals).
|
- `/context list` → what’s injected + rough sizes (per file + totals).
|
||||||
- `/context detail` → deeper breakdown: per-file, per-tool schema sizes, per-skill entry sizes, and system prompt size.
|
- `/context detail` → deeper breakdown: per-file, per-tool schema sizes, per-skill entry sizes, and system prompt size.
|
||||||
- `/cost on` → append per-reply usage line to normal replies.
|
- `/usage tokens` → append per-reply usage footer to normal replies.
|
||||||
- `/compact` → summarize older history into a compact entry to free window space.
|
- `/compact` → summarize older history into a compact entry to free window space.
|
||||||
|
|
||||||
See also: [Slash commands](/tools/slash-commands), [Token use & costs](/token-use), [Compaction](/concepts/compaction).
|
See also: [Slash commands](/tools/slash-commands), [Token use & costs](/token-use), [Compaction](/concepts/compaction).
|
||||||
@@ -149,4 +149,3 @@ Docs: [Session](/concepts/session), [Compaction](/concepts/compaction), [Session
|
|||||||
- `System prompt (estimate)` = computed on the fly when no run report exists (or when running via a CLI backend that doesn’t generate the report).
|
- `System prompt (estimate)` = computed on the fly when no run report exists (or when running via a CLI backend that doesn’t generate the report).
|
||||||
|
|
||||||
Either way, it reports sizes and top contributors; it does **not** dump the full system prompt or tool schemas.
|
Either way, it reports sizes and top contributors; it does **not** dump the full system prompt or tool schemas.
|
||||||
|
|
||||||
@@ -172,6 +172,44 @@ When enabled, Clawdbot combines:
|
|||||||
|
|
||||||
If full-text search is unavailable on your platform, Clawdbot falls back to vector-only search.
|
If full-text search is unavailable on your platform, Clawdbot falls back to vector-only search.
|
||||||
|
|
||||||
|
#### Why hybrid?
|
||||||
|
|
||||||
|
Vector search is great at “this means the same thing”:
|
||||||
|
- “Mac Studio gateway host” vs “the machine running the gateway”
|
||||||
|
- “debounce file updates” vs “avoid indexing on every write”
|
||||||
|
|
||||||
|
But it can be weak at exact, high-signal tokens:
|
||||||
|
- IDs (`a828e60`, `b3b9895a…`)
|
||||||
|
- code symbols (`memorySearch.query.hybrid`)
|
||||||
|
- error strings (“sqlite-vec unavailable”)
|
||||||
|
|
||||||
|
BM25 (full-text) is the opposite: strong at exact tokens, weaker at paraphrases.
|
||||||
|
Hybrid search is the pragmatic middle ground: **use both retrieval signals** so you get
|
||||||
|
good results for both “natural language” queries and “needle in a haystack” queries.
|
||||||
|
|
||||||
|
#### How we merge results (the current design)
|
||||||
|
|
||||||
|
Implementation sketch:
|
||||||
|
|
||||||
|
1) Retrieve a candidate pool from both sides:
|
||||||
|
- **Vector**: top `maxResults * candidateMultiplier` by cosine similarity.
|
||||||
|
- **BM25**: top `maxResults * candidateMultiplier` by FTS5 BM25 rank (lower is better).
|
||||||
|
|
||||||
|
2) Convert BM25 rank into a 0..1-ish score:
|
||||||
|
- `textScore = 1 / (1 + max(0, bm25Rank))`
|
||||||
|
|
||||||
|
3) Union candidates by chunk id and compute a weighted score:
|
||||||
|
- `finalScore = vectorWeight * vectorScore + textWeight * textScore`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `vectorWeight` + `textWeight` is normalized to 1.0 in config resolution, so weights behave as percentages.
|
||||||
|
- If embeddings are unavailable (or the provider returns a zero-vector), we still run BM25 and return keyword matches.
|
||||||
|
- If FTS5 can’t be created, we keep vector-only search (no hard failure).
|
||||||
|
|
||||||
|
This isn’t “IR-theory perfect”, but it’s simple, fast, and tends to improve recall/precision on real notes.
|
||||||
|
If we want to get fancier later, common next steps are Reciprocal Rank Fusion (RRF) or score normalization
|
||||||
|
(min/max or z-score) before mixing.
|
||||||
|
|
||||||
Config:
|
Config:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
|
|||||||
@@ -54,8 +54,12 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
|||||||
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
|
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
|
||||||
- Node bridge runs: `node-<nodeId>`
|
- Node bridge runs: `node-<nodeId>`
|
||||||
|
|
||||||
## Lifecyle
|
## Lifecycle
|
||||||
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
|
- Reset policy: sessions are reused until they expire, and expiry is evaluated on the next inbound message.
|
||||||
|
- Daily reset: defaults to **4:00 AM local time on the gateway host**. A session is stale once its last update is earlier than the most recent daily reset time.
|
||||||
|
- Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
|
||||||
|
- Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility.
|
||||||
|
- Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
|
||||||
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
|
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
|
||||||
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
||||||
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
|
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
|
||||||
@@ -93,7 +97,18 @@ Send these as standalone messages so they register.
|
|||||||
identityLinks: {
|
identityLinks: {
|
||||||
alice: ["telegram:123456789", "discord:987654321012345678"]
|
alice: ["telegram:123456789", "discord:987654321012345678"]
|
||||||
},
|
},
|
||||||
idleMinutes: 120,
|
reset: {
|
||||||
|
// Defaults: mode=daily, atHour=4 (gateway host local time).
|
||||||
|
// If you also set idleMinutes, whichever expires first wins.
|
||||||
|
mode: "daily",
|
||||||
|
atHour: 4,
|
||||||
|
idleMinutes: 120
|
||||||
|
},
|
||||||
|
resetByType: {
|
||||||
|
thread: { mode: "daily", atHour: 4 },
|
||||||
|
dm: { mode: "idle", idleMinutes: 240 },
|
||||||
|
group: { mode: "idle", idleMinutes: 120 }
|
||||||
|
},
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
||||||
mainKey: "main",
|
mainKey: "main",
|
||||||
@@ -122,3 +137,10 @@ Each session entry records where it came from (best-effort) in `origin`:
|
|||||||
- `from`/`to`: raw routing ids from the inbound envelope
|
- `from`/`to`: raw routing ids from the inbound envelope
|
||||||
- `accountId`: provider account id (when multi-account)
|
- `accountId`: provider account id (when multi-account)
|
||||||
- `threadId`: thread/topic id when the channel supports it
|
- `threadId`: thread/topic id when the channel supports it
|
||||||
|
The origin fields are populated for direct messages, channels, and groups. If a
|
||||||
|
connector only updates delivery routing (for example, to keep a DM main session
|
||||||
|
fresh), it should still provide inbound context so the session keeps its
|
||||||
|
explainer metadata. Extensions can do this by sending `ConversationLabel`,
|
||||||
|
`GroupSubject`, `GroupChannel`, `GroupSpace`, and `SenderName` in the inbound
|
||||||
|
context and calling `recordSessionMetaFromInbound` (or passing the same context
|
||||||
|
to `updateLastRoute`).
|
||||||
@@ -58,6 +58,9 @@ Large files are truncated with a marker. The max per-file size is controlled by
|
|||||||
`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
|
`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
|
||||||
short missing-file marker.
|
short missing-file marker.
|
||||||
|
|
||||||
|
Internal hooks can intercept this step via `agent:bootstrap` to mutate or replace
|
||||||
|
the injected bootstrap files (for example swapping `SOUL.md` for an alternate persona).
|
||||||
|
|
||||||
To inspect how much each injected file contributes (raw vs injected, truncation, plus tool schema overhead), use `/context list` or `/context detail`. See [Context](/concepts/context).
|
To inspect how much each injected file contributes (raw vs injected, truncation, plus tool schema overhead), use `/context list` or `/context detail`. See [Context](/concepts/context).
|
||||||
|
|
||||||
## Time handling
|
## Time handling
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ read_when:
|
|||||||
|
|
||||||
## Where it shows up
|
## Where it shows up
|
||||||
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
|
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
|
||||||
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
|
- `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only).
|
||||||
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
||||||
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||||
- macOS menu bar: “Usage” section under Context (only if available).
|
- macOS menu bar: “Usage” section under Context (only if available).
|
||||||
|
|||||||
@@ -956,6 +956,8 @@
|
|||||||
{
|
{
|
||||||
"group": "Automation & Hooks",
|
"group": "Automation & Hooks",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
"hooks",
|
||||||
|
"hooks/soul-evil",
|
||||||
"automation/auth-monitoring",
|
"automation/auth-monitoring",
|
||||||
"automation/webhook",
|
"automation/webhook",
|
||||||
"automation/gmail-pubsub",
|
"automation/gmail-pubsub",
|
||||||
|
|||||||
@@ -146,7 +146,11 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
|||||||
// Session behavior
|
// Session behavior
|
||||||
session: {
|
session: {
|
||||||
scope: "per-sender",
|
scope: "per-sender",
|
||||||
idleMinutes: 60,
|
reset: {
|
||||||
|
mode: "daily",
|
||||||
|
atHour: 4,
|
||||||
|
idleMinutes: 60
|
||||||
|
},
|
||||||
heartbeatIdleMinutes: 120,
|
heartbeatIdleMinutes: 120,
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
store: "~/.clawdbot/agents/default/sessions/sessions.json",
|
store: "~/.clawdbot/agents/default/sessions/sessions.json",
|
||||||
|
|||||||
@@ -2416,7 +2416,7 @@ Notes:
|
|||||||
|
|
||||||
### `session`
|
### `session`
|
||||||
|
|
||||||
Controls session scoping, idle expiry, reset triggers, and where the session store is written.
|
Controls session scoping, reset policy, reset triggers, and where the session store is written.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
@@ -2426,7 +2426,16 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
|||||||
identityLinks: {
|
identityLinks: {
|
||||||
alice: ["telegram:123456789", "discord:987654321012345678"]
|
alice: ["telegram:123456789", "discord:987654321012345678"]
|
||||||
},
|
},
|
||||||
idleMinutes: 60,
|
reset: {
|
||||||
|
mode: "daily",
|
||||||
|
atHour: 4,
|
||||||
|
idleMinutes: 60
|
||||||
|
},
|
||||||
|
resetByType: {
|
||||||
|
thread: { mode: "daily", atHour: 4 },
|
||||||
|
dm: { mode: "idle", idleMinutes: 240 },
|
||||||
|
group: { mode: "idle", idleMinutes: 120 }
|
||||||
|
},
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
||||||
// You can override with {agentId} templating:
|
// You can override with {agentId} templating:
|
||||||
@@ -2437,12 +2446,12 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
|||||||
// Max ping-pong reply turns between requester/target (0–5).
|
// Max ping-pong reply turns between requester/target (0–5).
|
||||||
maxPingPongTurns: 5
|
maxPingPongTurns: 5
|
||||||
},
|
},
|
||||||
sendPolicy: {
|
sendPolicy: {
|
||||||
rules: [
|
rules: [
|
||||||
{ action: "deny", match: { channel: "discord", chatType: "group" } }
|
{ action: "deny", match: { channel: "discord", chatType: "group" } }
|
||||||
],
|
],
|
||||||
default: "allow"
|
default: "allow"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -2456,6 +2465,13 @@ Fields:
|
|||||||
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
|
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
|
||||||
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
|
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
|
||||||
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
|
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
|
||||||
|
- `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host.
|
||||||
|
- `mode`: `daily` or `idle` (default: `daily` when `reset` is present).
|
||||||
|
- `atHour`: local hour (0-23) for the daily reset boundary.
|
||||||
|
- `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins.
|
||||||
|
- `resetByType`: per-session overrides for `dm`, `group`, and `thread`.
|
||||||
|
- If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility.
|
||||||
|
- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled).
|
||||||
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
||||||
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
||||||
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
||||||
|
|||||||
@@ -239,11 +239,15 @@ Known issue: When you send an image with ONLY a mention (no other text), WhatsAp
|
|||||||
ls -la ~/.clawdbot/agents/<agentId>/sessions/
|
ls -la ~/.clawdbot/agents/<agentId>/sessions/
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check 2:** Is `idleMinutes` too short?
|
**Check 2:** Is the reset window too short?
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"session": {
|
"session": {
|
||||||
"idleMinutes": 10080 // 7 days
|
"reset": {
|
||||||
|
"mode": "daily",
|
||||||
|
"atHour": 4,
|
||||||
|
"idleMinutes": 10080 // 7 days
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+46
-1
@@ -14,6 +14,8 @@ Hooks are small scripts that run when something happens. There are two kinds:
|
|||||||
|
|
||||||
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
||||||
- **Webhooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
|
- **Webhooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
|
||||||
|
|
||||||
|
Hooks can also be bundled inside plugins; see [Plugins](/plugin#plugin-hooks).
|
||||||
|
|
||||||
Common uses:
|
Common uses:
|
||||||
- Save a memory snapshot when you reset a session
|
- Save a memory snapshot when you reset a session
|
||||||
@@ -35,10 +37,11 @@ The hooks system allows you to:
|
|||||||
|
|
||||||
### Bundled Hooks
|
### Bundled Hooks
|
||||||
|
|
||||||
Clawdbot ships with two bundled hooks that are automatically discovered:
|
Clawdbot ships with three bundled hooks that are automatically discovered:
|
||||||
|
|
||||||
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
|
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
|
||||||
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
|
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
|
||||||
|
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
|
||||||
|
|
||||||
List available hooks:
|
List available hooks:
|
||||||
|
|
||||||
@@ -203,6 +206,8 @@ Each event includes:
|
|||||||
sessionFile?: string,
|
sessionFile?: string,
|
||||||
commandSource?: string, // e.g., 'whatsapp', 'telegram'
|
commandSource?: string, // e.g., 'whatsapp', 'telegram'
|
||||||
senderId?: string,
|
senderId?: string,
|
||||||
|
workspaceDir?: string,
|
||||||
|
bootstrapFiles?: WorkspaceBootstrapFile[],
|
||||||
cfg?: ClawdbotConfig
|
cfg?: ClawdbotConfig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,6 +224,10 @@ Triggered when agent commands are issued:
|
|||||||
- **`command:reset`**: When `/reset` command is issued
|
- **`command:reset`**: When `/reset` command is issued
|
||||||
- **`command:stop`**: When `/stop` command is issued
|
- **`command:stop`**: When `/stop` command is issued
|
||||||
|
|
||||||
|
### Agent Events
|
||||||
|
|
||||||
|
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
|
||||||
|
|
||||||
### Future Events
|
### Future Events
|
||||||
|
|
||||||
Planned event types:
|
Planned event types:
|
||||||
@@ -497,6 +506,42 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
|
|||||||
clawdbot hooks enable command-logger
|
clawdbot hooks enable command-logger
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### soul-evil
|
||||||
|
|
||||||
|
Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance.
|
||||||
|
|
||||||
|
**Events**: `agent:bootstrap`
|
||||||
|
|
||||||
|
**Docs**: [SOUL Evil Hook](/hooks/soul-evil)
|
||||||
|
|
||||||
|
**Output**: No files written; swaps happen in-memory only.
|
||||||
|
|
||||||
|
**Enable**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot hooks enable soul-evil
|
||||||
|
```
|
||||||
|
|
||||||
|
**Config**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"internal": {
|
||||||
|
"enabled": true,
|
||||||
|
"entries": {
|
||||||
|
"soul-evil": {
|
||||||
|
"enabled": true,
|
||||||
|
"file": "SOUL_EVIL.md",
|
||||||
|
"chance": 0.1,
|
||||||
|
"purge": { "at": "21:00", "duration": "15m" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
### Keep Handlers Fast
|
### Keep Handlers Fast
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
summary: "SOUL Evil hook (swap SOUL.md with SOUL_EVIL.md)"
|
||||||
|
read_when:
|
||||||
|
- You want to enable or tune the SOUL Evil hook
|
||||||
|
- You want a purge window or random-chance persona swap
|
||||||
|
---
|
||||||
|
|
||||||
|
# SOUL Evil Hook
|
||||||
|
|
||||||
|
The SOUL Evil hook swaps the **injected** `SOUL.md` content with `SOUL_EVIL.md` during
|
||||||
|
a purge window or by random chance. It does **not** modify files on disk.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
When `agent:bootstrap` runs, the hook can replace the `SOUL.md` content in memory
|
||||||
|
before the system prompt is assembled. If `SOUL_EVIL.md` is missing or empty,
|
||||||
|
Clawdbot logs a warning and keeps the normal `SOUL.md`.
|
||||||
|
|
||||||
|
Sub-agent runs do **not** include `SOUL.md` in their bootstrap files, so this hook
|
||||||
|
has no effect on sub-agents.
|
||||||
|
|
||||||
|
## Enable
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot hooks enable soul-evil
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set the config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"internal": {
|
||||||
|
"enabled": true,
|
||||||
|
"entries": {
|
||||||
|
"soul-evil": {
|
||||||
|
"enabled": true,
|
||||||
|
"file": "SOUL_EVIL.md",
|
||||||
|
"chance": 0.1,
|
||||||
|
"purge": { "at": "21:00", "duration": "15m" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `SOUL_EVIL.md` in the agent workspace root (next to `SOUL.md`).
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
- `file` (string): alternate SOUL filename (default: `SOUL_EVIL.md`)
|
||||||
|
- `chance` (number 0–1): random chance per run to use `SOUL_EVIL.md`
|
||||||
|
- `purge.at` (HH:mm): daily purge start (24-hour clock)
|
||||||
|
- `purge.duration` (duration): window length (e.g. `30s`, `10m`, `1h`)
|
||||||
|
|
||||||
|
**Precedence:** purge window wins over chance.
|
||||||
|
|
||||||
|
**Timezone:** uses `agents.defaults.userTimezone` when set; otherwise host timezone.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No files are written or modified on disk.
|
||||||
|
- If `SOUL.md` is not in the bootstrap list, the hook does nothing.
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Hooks](/hooks)
|
||||||
+24
-17
@@ -58,6 +58,7 @@ register:
|
|||||||
- Optional config validation
|
- Optional config validation
|
||||||
|
|
||||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||||
|
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||||
|
|
||||||
## Discovery & precedence
|
## Discovery & precedence
|
||||||
|
|
||||||
@@ -214,6 +215,27 @@ Plugins export either:
|
|||||||
- A function: `(api) => { ... }`
|
- A function: `(api) => { ... }`
|
||||||
- An object: `{ id, name, configSchema, register(api) { ... } }`
|
- An object: `{ id, name, configSchema, register(api) { ... } }`
|
||||||
|
|
||||||
|
## Plugin hooks
|
||||||
|
|
||||||
|
Plugins can ship hooks and register them at runtime. This lets a plugin bundle
|
||||||
|
event-driven automation without a separate hook pack install.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```
|
||||||
|
import { registerPluginHooksFromDir } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
export default function register(api) {
|
||||||
|
registerPluginHooksFromDir(api, "./hooks");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Hook directories follow the normal hook structure (`HOOK.md` + `handler.ts`).
|
||||||
|
- Hook eligibility rules still apply (OS/bins/env/config requirements).
|
||||||
|
- Plugin-managed hooks show up in `clawdbot hooks list` with `plugin:<id>`.
|
||||||
|
- You cannot enable/disable plugin-managed hooks via `clawdbot hooks`; enable/disable the plugin instead.
|
||||||
|
|
||||||
## Provider plugins (model auth)
|
## Provider plugins (model auth)
|
||||||
|
|
||||||
Plugins can register **model provider auth** flows so users can run OAuth or
|
Plugins can register **model provider auth** flows so users can run OAuth or
|
||||||
@@ -379,24 +401,9 @@ export default function (api) {
|
|||||||
Load the plugin (extensions dir or `plugins.load.paths`), restart the gateway,
|
Load the plugin (extensions dir or `plugins.load.paths`), restart the gateway,
|
||||||
then configure `channels.<id>` in your config.
|
then configure `channels.<id>` in your config.
|
||||||
|
|
||||||
### Register a tool
|
### Agent tools
|
||||||
|
|
||||||
```ts
|
See the dedicated guide: [Plugin agent tools](/plugins/agent-tools).
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
|
|
||||||
export default function (api) {
|
|
||||||
api.registerTool({
|
|
||||||
name: "my_tool",
|
|
||||||
description: "Do a thing",
|
|
||||||
parameters: Type.Object({
|
|
||||||
input: Type.String(),
|
|
||||||
}),
|
|
||||||
async execute(_id, params) {
|
|
||||||
return { content: [{ type: "text", text: params.input }] };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Register a gateway RPC method
|
### Register a gateway RPC method
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
summary: "Write agent tools in a plugin (schemas, optional tools, allowlists)"
|
||||||
|
read_when:
|
||||||
|
- You want to add a new agent tool in a plugin
|
||||||
|
- You need to make a tool opt-in via allowlists
|
||||||
|
---
|
||||||
|
# Plugin agent tools
|
||||||
|
|
||||||
|
Clawdbot plugins can register **agent tools** (JSON‑schema functions) that are exposed
|
||||||
|
to the LLM during agent runs. Tools can be **required** (always available) or
|
||||||
|
**optional** (opt‑in).
|
||||||
|
|
||||||
|
Agent tools are configured under `tools` in the main config, or per‑agent under
|
||||||
|
`agents.list[].tools`. The allowlist/denylist policy controls which tools the agent
|
||||||
|
can call.
|
||||||
|
|
||||||
|
## Basic tool
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
export default function (api) {
|
||||||
|
api.registerTool({
|
||||||
|
name: "my_tool",
|
||||||
|
description: "Do a thing",
|
||||||
|
parameters: Type.Object({
|
||||||
|
input: Type.String(),
|
||||||
|
}),
|
||||||
|
async execute(_id, params) {
|
||||||
|
return { content: [{ type: "text", text: params.input }] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optional tool (opt‑in)
|
||||||
|
|
||||||
|
Optional tools are **never** auto‑enabled. Users must add them to an agent
|
||||||
|
allowlist.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export default function (api) {
|
||||||
|
api.registerTool(
|
||||||
|
{
|
||||||
|
name: "workflow_tool",
|
||||||
|
description: "Run a local workflow",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
pipeline: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["pipeline"],
|
||||||
|
},
|
||||||
|
async execute(_id, params) {
|
||||||
|
return { content: [{ type: "text", text: params.pipeline }] };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ optional: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable optional tools in `agents.list[].tools.allow` (or global `tools.allow`):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: "main",
|
||||||
|
tools: {
|
||||||
|
allow: [
|
||||||
|
"workflow_tool", // specific tool name
|
||||||
|
"workflow", // plugin id (enables all tools from that plugin)
|
||||||
|
"group:plugins" // all plugin tools
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Other config knobs that affect tool availability:
|
||||||
|
- `tools.profile` / `agents.list[].tools.profile` (base allowlist)
|
||||||
|
- `tools.byProvider` / `agents.list[].tools.byProvider` (provider‑specific allow/deny)
|
||||||
|
- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed)
|
||||||
|
|
||||||
|
## Rules + tips
|
||||||
|
|
||||||
|
- Tool names must **not** clash with core tool names; conflicting tools are skipped.
|
||||||
|
- Plugin ids used in allowlists must not clash with core tool names.
|
||||||
|
- Prefer `optional: true` for tools that trigger side effects or require extra
|
||||||
|
binaries/credentials.
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
---
|
||||||
|
summary: "Refactor plan: exec host routing, node approvals, and headless runner"
|
||||||
|
read_when:
|
||||||
|
- Designing exec host routing or exec approvals
|
||||||
|
- Implementing node runner + UI IPC
|
||||||
|
- Adding exec host security modes and slash commands
|
||||||
|
---
|
||||||
|
|
||||||
|
# Exec host refactor plan
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Add `exec.host` + `exec.security` to route execution across **sandbox**, **gateway**, and **node**.
|
||||||
|
- Keep defaults **safe**: no cross-host execution unless explicitly enabled.
|
||||||
|
- Split execution into a **headless runner service** with optional UI (macOS app) via local IPC.
|
||||||
|
- Provide **per-agent** policy, allowlist, ask mode, and node binding.
|
||||||
|
- Support **ask modes** that work *with* or *without* allowlists.
|
||||||
|
- Cross-platform: Unix socket + token auth (macOS/Linux/Windows parity).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
- No legacy allowlist migration or legacy schema support.
|
||||||
|
- No PTY/streaming for node exec (aggregated output only).
|
||||||
|
- No new network layer beyond the existing Bridge + Gateway.
|
||||||
|
|
||||||
|
## Decisions (locked)
|
||||||
|
- **Config keys:** `exec.host` + `exec.security` (per-agent override allowed).
|
||||||
|
- **Elevation:** keep `/elevated` as an alias for gateway full access.
|
||||||
|
- **Ask default:** `on-miss`.
|
||||||
|
- **Approvals store:** `~/.clawdbot/exec-approvals.json` (JSON, no legacy migration).
|
||||||
|
- **Runner:** headless system service; UI app hosts a Unix socket for approvals.
|
||||||
|
- **Node identity:** use existing `nodeId`.
|
||||||
|
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
||||||
|
|
||||||
|
## Key concepts
|
||||||
|
### Host
|
||||||
|
- `sandbox`: Docker exec (current behavior).
|
||||||
|
- `gateway`: exec on gateway host.
|
||||||
|
- `node`: exec on node runner via Bridge (`system.run`).
|
||||||
|
|
||||||
|
### Security mode
|
||||||
|
- `deny`: always block.
|
||||||
|
- `allowlist`: allow only matches.
|
||||||
|
- `full`: allow everything (equivalent to elevated).
|
||||||
|
|
||||||
|
### Ask mode
|
||||||
|
- `off`: never ask.
|
||||||
|
- `on-miss`: ask only when allowlist does not match.
|
||||||
|
- `always`: ask every time.
|
||||||
|
|
||||||
|
Ask is **independent** of allowlist; allowlist can be used with `always` or `on-miss`.
|
||||||
|
|
||||||
|
### Policy resolution (per exec)
|
||||||
|
1) Resolve `exec.host` (tool param → agent override → global default).
|
||||||
|
2) Resolve `exec.security` and `exec.ask` (same precedence).
|
||||||
|
3) If host is `sandbox`, proceed with local sandbox exec.
|
||||||
|
4) If host is `gateway` or `node`, apply security + ask policy on that host.
|
||||||
|
|
||||||
|
## Default safety
|
||||||
|
- Default `exec.host = sandbox`.
|
||||||
|
- Default `exec.security = deny` for `gateway` and `node`.
|
||||||
|
- Default `exec.ask = on-miss` (only relevant if security allows).
|
||||||
|
- If no node binding is set, **agent may target any node**, but only if policy allows it.
|
||||||
|
|
||||||
|
## Config surface
|
||||||
|
### Tool parameters
|
||||||
|
- `exec.host` (optional): `sandbox | gateway | node`.
|
||||||
|
- `exec.security` (optional): `deny | allowlist | full`.
|
||||||
|
- `exec.ask` (optional): `off | on-miss | always`.
|
||||||
|
- `exec.node` (optional): node id/name to use when `host=node`.
|
||||||
|
|
||||||
|
### Config keys (global)
|
||||||
|
- `tools.exec.host`
|
||||||
|
- `tools.exec.security`
|
||||||
|
- `tools.exec.ask`
|
||||||
|
- `tools.exec.node` (default node binding)
|
||||||
|
|
||||||
|
### Config keys (per agent)
|
||||||
|
- `agents.list[].tools.exec.host`
|
||||||
|
- `agents.list[].tools.exec.security`
|
||||||
|
- `agents.list[].tools.exec.ask`
|
||||||
|
- `agents.list[].tools.exec.node`
|
||||||
|
|
||||||
|
### Alias
|
||||||
|
- `/elevated on` = set `tools.exec.host=gateway`, `tools.exec.security=full` for the agent session.
|
||||||
|
- `/elevated off` = restore previous exec settings for the agent session.
|
||||||
|
|
||||||
|
## Approvals store (JSON)
|
||||||
|
Path: `~/.clawdbot/exec-approvals.json`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- Local policy + allowlists for the **execution host** (gateway or node runner).
|
||||||
|
- Ask fallback when no UI is available.
|
||||||
|
- IPC credentials for UI clients.
|
||||||
|
|
||||||
|
Proposed schema (v1):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"socket": {
|
||||||
|
"path": "~/.clawdbot/exec-approvals.sock",
|
||||||
|
"token": "base64-opaque-token"
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"security": "deny",
|
||||||
|
"ask": "on-miss",
|
||||||
|
"askFallback": "deny"
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"agent-id-1": {
|
||||||
|
"security": "allowlist",
|
||||||
|
"ask": "on-miss",
|
||||||
|
"allowlist": [
|
||||||
|
{
|
||||||
|
"pattern": "~/Projects/**/bin/rg",
|
||||||
|
"lastUsedAt": 0,
|
||||||
|
"lastUsedCommand": "rg -n TODO",
|
||||||
|
"lastResolvedPath": "/Users/user/Projects/.../bin/rg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Notes:
|
||||||
|
- No legacy allowlist formats.
|
||||||
|
- `askFallback` applies only when `ask` is required and no UI is reachable.
|
||||||
|
- File permissions: `0600`.
|
||||||
|
|
||||||
|
## Runner service (headless)
|
||||||
|
### Role
|
||||||
|
- Enforce `exec.security` + `exec.ask` locally.
|
||||||
|
- Execute system commands and return output.
|
||||||
|
- Emit Bridge events for exec lifecycle (optional but recommended).
|
||||||
|
|
||||||
|
### Service lifecycle
|
||||||
|
- Launchd/daemon on macOS; system service on Linux/Windows.
|
||||||
|
- Approvals JSON is local to the execution host.
|
||||||
|
- UI hosts a local Unix socket; runners connect on demand.
|
||||||
|
|
||||||
|
## UI integration (macOS app)
|
||||||
|
### IPC
|
||||||
|
- Unix socket at `~/.clawdbot/exec-approvals.sock`.
|
||||||
|
- Runner connects and sends an approval request; UI responds with a decision.
|
||||||
|
- Token stored in `exec-approvals.json`.
|
||||||
|
|
||||||
|
### Ask flow
|
||||||
|
1) Runner receives `system.run` from gateway.
|
||||||
|
2) If ask required, runner connects to the socket and sends a prompt request.
|
||||||
|
3) UI shows dialog; returns decision.
|
||||||
|
4) Runner enforces decision and proceeds.
|
||||||
|
|
||||||
|
If UI missing:
|
||||||
|
- Apply `askFallback` (`deny|allowlist|full`).
|
||||||
|
|
||||||
|
## Node identity + binding
|
||||||
|
- Use existing `nodeId` from Bridge pairing.
|
||||||
|
- Binding model:
|
||||||
|
- `tools.exec.node` restricts the agent to a specific node.
|
||||||
|
- If unset, agent can pick any node (policy still enforces defaults).
|
||||||
|
- Node selection resolution:
|
||||||
|
- `nodeId` exact match
|
||||||
|
- `displayName` (normalized)
|
||||||
|
- `remoteIp`
|
||||||
|
- `nodeId` prefix (>= 6 chars)
|
||||||
|
|
||||||
|
## Eventing
|
||||||
|
### Who sees events
|
||||||
|
- System events are **per session** and shown to the agent on the next prompt.
|
||||||
|
- Stored in the gateway in-memory queue (`enqueueSystemEvent`).
|
||||||
|
|
||||||
|
### Event text
|
||||||
|
- `Exec started (host=node, node=<id>, id=<runId>)`
|
||||||
|
- `Exec finished (exit=<code>, tail=<...>)`
|
||||||
|
- `Exec denied (policy=<...>, reason=<...>)`
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
Option A (recommended):
|
||||||
|
- Runner sends Bridge `event` frames `exec.started` / `exec.finished`.
|
||||||
|
- Gateway `handleBridgeEvent` maps these into `enqueueSystemEvent`.
|
||||||
|
|
||||||
|
Option B:
|
||||||
|
- Gateway `exec` tool handles lifecycle directly (synchronous only).
|
||||||
|
|
||||||
|
## Exec flows
|
||||||
|
### Sandbox host
|
||||||
|
- Existing `exec` behavior (Docker or host when unsandboxed).
|
||||||
|
- PTY supported in non-sandbox mode only.
|
||||||
|
|
||||||
|
### Gateway host
|
||||||
|
- Gateway process executes on its own machine.
|
||||||
|
- Enforces local `exec-approvals.json` (security/ask/allowlist).
|
||||||
|
|
||||||
|
### Node host
|
||||||
|
- Gateway calls `node.invoke` with `system.run`.
|
||||||
|
- Runner enforces local approvals.
|
||||||
|
- Runner returns aggregated stdout/stderr.
|
||||||
|
- Optional Bridge events for start/finish/deny.
|
||||||
|
|
||||||
|
## Output caps
|
||||||
|
- Cap combined stdout+stderr at **200k**; keep **tail 20k** for events.
|
||||||
|
- Truncate with a clear suffix (e.g., `"… (truncated)"`).
|
||||||
|
|
||||||
|
## Slash commands
|
||||||
|
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>`
|
||||||
|
- Per-agent, per-session overrides; non-persistent unless saved via config.
|
||||||
|
- `/elevated on|off` remains a shortcut for `host=gateway security=full`.
|
||||||
|
|
||||||
|
## Cross-platform story
|
||||||
|
- The runner service is the portable execution target.
|
||||||
|
- UI is optional; if missing, `askFallback` applies.
|
||||||
|
- Windows/Linux support the same approvals JSON + socket protocol.
|
||||||
|
|
||||||
|
## Implementation phases
|
||||||
|
### Phase 1: config + exec routing
|
||||||
|
- Add config schema for `exec.host`, `exec.security`, `exec.ask`, `exec.node`.
|
||||||
|
- Update tool plumbing to respect `exec.host`.
|
||||||
|
- Add `/exec` slash command and keep `/elevated` alias.
|
||||||
|
|
||||||
|
### Phase 2: approvals store + gateway enforcement
|
||||||
|
- Implement `exec-approvals.json` reader/writer.
|
||||||
|
- Enforce allowlist + ask modes for `gateway` host.
|
||||||
|
- Add output caps.
|
||||||
|
|
||||||
|
### Phase 3: node runner enforcement
|
||||||
|
- Update node runner to enforce allowlist + ask.
|
||||||
|
- Add Unix socket prompt bridge to macOS app UI.
|
||||||
|
- Wire `askFallback`.
|
||||||
|
|
||||||
|
### Phase 4: events
|
||||||
|
- Add node → gateway Bridge events for exec lifecycle.
|
||||||
|
- Map to `enqueueSystemEvent` for agent prompts.
|
||||||
|
|
||||||
|
### Phase 5: UI polish
|
||||||
|
- Mac app: allowlist editor, per-agent switcher, ask policy UI.
|
||||||
|
- Node binding controls (optional).
|
||||||
|
|
||||||
|
## Testing plan
|
||||||
|
- Unit tests: allowlist matching (glob + case-insensitive).
|
||||||
|
- Unit tests: policy resolution precedence (tool param → agent override → global).
|
||||||
|
- Integration tests: node runner deny/allow/ask flows.
|
||||||
|
- Bridge event tests: node event → system event routing.
|
||||||
|
|
||||||
|
## Open risks
|
||||||
|
- UI unavailability: ensure `askFallback` is respected.
|
||||||
|
- Long-running commands: rely on timeout + output caps.
|
||||||
|
- Multi-node ambiguity: error unless node binding or explicit node param.
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
- [Exec tool](/tools/exec)
|
||||||
|
- [Exec approvals](/tools/exec-approvals)
|
||||||
|
- [Nodes](/nodes)
|
||||||
|
- [Elevated mode](/tools/elevated)
|
||||||
@@ -82,7 +82,8 @@ Each `sessionKey` points at a current `sessionId` (the transcript file that cont
|
|||||||
|
|
||||||
Rules of thumb:
|
Rules of thumb:
|
||||||
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
|
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
|
||||||
- **Idle expiry** (`session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window.
|
- **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary.
|
||||||
|
- **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins.
|
||||||
|
|
||||||
Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`.
|
Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`.
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -160,7 +160,11 @@ Example:
|
|||||||
session: {
|
session: {
|
||||||
scope: "per-sender",
|
scope: "per-sender",
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
idleMinutes: 10080
|
reset: {
|
||||||
|
mode: "daily",
|
||||||
|
atHour: 4,
|
||||||
|
idleMinutes: 10080
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+9
-4
@@ -880,14 +880,19 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce
|
|||||||
|
|
||||||
### Do sessions reset automatically if I never send `/new`?
|
### Do sessions reset automatically if I never send `/new`?
|
||||||
|
|
||||||
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next**
|
Yes. By default sessions reset daily at **4:00 AM local time** on the gateway host.
|
||||||
message starts a fresh session id for that chat key. This does not delete
|
You can also add an idle window; when both daily and idle resets are configured,
|
||||||
transcripts — it just starts a new session.
|
whichever expires first starts a new session id on the next message. This does
|
||||||
|
not delete transcripts — it just starts a new session.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
session: {
|
session: {
|
||||||
idleMinutes: 240
|
reset: {
|
||||||
|
mode: "daily",
|
||||||
|
atHour: 4,
|
||||||
|
idleMinutes: 240
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-2
@@ -42,13 +42,13 @@ Use these in chat:
|
|||||||
|
|
||||||
- `/status` → **emoji‑rich status card** with the session model, context usage,
|
- `/status` → **emoji‑rich status card** with the session model, context usage,
|
||||||
last response input/output tokens, and **estimated cost** (API key only).
|
last response input/output tokens, and **estimated cost** (API key only).
|
||||||
- `/cost on|off` → appends a **per-response usage line** to every reply.
|
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
|
||||||
- Persists per session (stored as `responseUsage`).
|
- Persists per session (stored as `responseUsage`).
|
||||||
- OAuth auth **hides cost** (tokens only).
|
- OAuth auth **hides cost** (tokens only).
|
||||||
|
|
||||||
Other surfaces:
|
Other surfaces:
|
||||||
|
|
||||||
- **TUI/Web TUI:** `/status` + `/cost` are supported.
|
- **TUI/Web TUI:** `/status` + `/usage` are supported.
|
||||||
- **CLI:** `clawdbot status --usage` and `clawdbot channels list` show
|
- **CLI:** `clawdbot status --usage` and `clawdbot channels list` show
|
||||||
provider quota windows (not per-response costs).
|
provider quota windows (not per-response costs).
|
||||||
|
|
||||||
|
|||||||
+5
-15
@@ -6,9 +6,8 @@ read_when:
|
|||||||
# Elevated Mode (/elevated directives)
|
# Elevated Mode (/elevated directives)
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
- Elevated mode allows the exec tool to run with elevated privileges when the feature is available and the sender is approved.
|
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full`.
|
||||||
- The bash chat command (`!`; `/bash` alias) uses the same `tools.elevated` allowlists because it always runs on the host.
|
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
|
||||||
- **Optional for sandboxed agents**: elevated only changes behavior when the agent is running in a sandbox. If the agent already runs unsandboxed, elevated is effectively a no-op.
|
|
||||||
- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`.
|
- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`.
|
||||||
- Only `on|off` are accepted; anything else returns a hint and does not change state.
|
- Only `on|off` are accepted; anything else returns a hint and does not change state.
|
||||||
|
|
||||||
@@ -17,18 +16,9 @@ read_when:
|
|||||||
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
|
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
|
||||||
- **Inline directive**: `/elevated on` inside a message applies to that message only.
|
- **Inline directive**: `/elevated on` inside a message applies to that message only.
|
||||||
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
|
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
|
||||||
- **Host execution**: elevated runs `exec` on the host (bypasses sandbox).
|
- **Host execution**: elevated forces `exec` onto the gateway host with full security.
|
||||||
- **Unsandboxed agents**: when there is no sandbox to bypass, elevated does not change where `exec` runs.
|
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
|
||||||
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
|
- **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.
|
|
||||||
- Sandbox off: `/elevated on` does not change execution (already on host).
|
|
||||||
|
|
||||||
## When elevated matters
|
|
||||||
- Only impacts `exec` when the agent is running sandboxed (it drops the sandbox for that command).
|
|
||||||
- For unsandboxed agents, elevated does not change execution; it only affects gating, logging, and status.
|
|
||||||
|
|
||||||
## Resolution order
|
## Resolution order
|
||||||
1. Inline directive on the message (applies only to that message).
|
1. Inline directive on the message (applies only to that message).
|
||||||
@@ -38,7 +28,7 @@ Note:
|
|||||||
## Setting a session default
|
## Setting a session default
|
||||||
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`.
|
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`.
|
||||||
- Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`).
|
- Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`).
|
||||||
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error (runtime sandboxed/direct + failing config key paths) and does not change session state.
|
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state.
|
||||||
- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level.
|
- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level.
|
||||||
|
|
||||||
## Availability + allowlists
|
## Availability + allowlists
|
||||||
|
|||||||
@@ -1,39 +1,88 @@
|
|||||||
---
|
---
|
||||||
summary: "Exec approvals, allowlists, and sandbox escape prompts in the macOS app"
|
summary: "Exec approvals, allowlists, and sandbox escape prompts"
|
||||||
read_when:
|
read_when:
|
||||||
- Configuring exec approvals or allowlists
|
- Configuring exec approvals or allowlists
|
||||||
- Implementing exec approval UX in the macOS app
|
- Implementing exec approval UX in the macOS app
|
||||||
- Reviewing sandbox escape prompts and implications
|
- Reviewing sandbox escape prompts and implications
|
||||||
---
|
---
|
||||||
|
|
||||||
# Exec approvals (macOS app)
|
# Exec approvals
|
||||||
|
|
||||||
Exec approvals are the **macOS companion app** guardrail for running host
|
Exec approvals are the **companion app guardrail** for letting a sandboxed agent run
|
||||||
commands from sandboxed agents. Think of it as a per-agent “run this on my Mac”
|
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
|
||||||
approval layer: the agent asks, the app decides, and the command runs (or not).
|
commands are allowed only when policy + allowlist + (optional) user approval all agree.
|
||||||
This is **in addition** to tool policy and elevated gating; all of those checks
|
Exec approvals are **in addition** to tool policy and elevated gating.
|
||||||
must pass before a command can run.
|
|
||||||
|
|
||||||
If you are **not** running the macOS companion app, exec approvals are
|
If the companion app UI is **not available**, any request that requires a prompt is
|
||||||
unavailable and `system.run` requests will be rejected with a message that a
|
resolved by the **ask fallback** (default: deny).
|
||||||
companion app is required.
|
|
||||||
|
|
||||||
## Settings
|
## Where it applies
|
||||||
|
|
||||||
In the macOS app, each agent has an **Exec approvals** setting:
|
Exec approvals are enforced locally on the execution host:
|
||||||
|
- **gateway host** → `clawdbot` process on the gateway machine
|
||||||
|
- **node host** → node runner (macOS companion app or headless node)
|
||||||
|
|
||||||
- **Deny**: block all host exec requests from the agent.
|
## Settings and storage
|
||||||
- **Always ask**: show a confirmation dialog for each host exec request.
|
|
||||||
- **Always allow**: run host exec requests without prompting.
|
|
||||||
|
|
||||||
Optional toggles:
|
Approvals live in a local JSON file:
|
||||||
- **Auto-allow skill CLIs**: when enabled, CLIs referenced by known skills are
|
|
||||||
treated as allowlisted (see below).
|
`~/.clawdbot/exec-approvals.json`
|
||||||
|
|
||||||
|
Example schema:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"socket": {
|
||||||
|
"path": "~/.clawdbot/exec-approvals.sock",
|
||||||
|
"token": "base64url-token"
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"security": "deny",
|
||||||
|
"ask": "on-miss",
|
||||||
|
"askFallback": "deny",
|
||||||
|
"autoAllowSkills": false
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"main": {
|
||||||
|
"security": "allowlist",
|
||||||
|
"ask": "on-miss",
|
||||||
|
"askFallback": "deny",
|
||||||
|
"autoAllowSkills": true,
|
||||||
|
"allowlist": [
|
||||||
|
{
|
||||||
|
"pattern": "~/Projects/**/bin/rg",
|
||||||
|
"lastUsedAt": 1737150000000,
|
||||||
|
"lastUsedCommand": "rg -n TODO",
|
||||||
|
"lastResolvedPath": "/Users/user/Projects/.../bin/rg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Policy knobs
|
||||||
|
|
||||||
|
### Security (`exec.security`)
|
||||||
|
- **deny**: block all host exec requests.
|
||||||
|
- **allowlist**: allow only allowlisted commands.
|
||||||
|
- **full**: allow everything (equivalent to elevated).
|
||||||
|
|
||||||
|
### Ask (`exec.ask`)
|
||||||
|
- **off**: never prompt.
|
||||||
|
- **on-miss**: prompt only when allowlist does not match.
|
||||||
|
- **always**: prompt on every command.
|
||||||
|
|
||||||
|
### Ask fallback (`askFallback`)
|
||||||
|
If a prompt is required but no UI is reachable, fallback decides:
|
||||||
|
- **deny**: block.
|
||||||
|
- **allowlist**: allow only if allowlist matches.
|
||||||
|
- **full**: allow.
|
||||||
|
|
||||||
## Allowlist (per agent)
|
## Allowlist (per agent)
|
||||||
|
|
||||||
The allowlist is **per agent**. If multiple agents exist, you can switch which
|
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
|
||||||
agent’s allowlist you’re editing. Entries are path-based and support **globs**.
|
editing in the macOS app. Patterns are **case-insensitive glob matches**.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- `~/Projects/**/bin/bird`
|
- `~/Projects/**/bin/bird`
|
||||||
@@ -41,66 +90,44 @@ Examples:
|
|||||||
- `/opt/homebrew/bin/rg`
|
- `/opt/homebrew/bin/rg`
|
||||||
|
|
||||||
Each allowlist entry tracks:
|
Each allowlist entry tracks:
|
||||||
- **last used** (timestamp)
|
- **last used** timestamp
|
||||||
- **last used command**
|
- **last used command**
|
||||||
- **last used path** (resolved absolute path)
|
- **last resolved path**
|
||||||
- **last seen metadata** (hash/version/mtime when available)
|
|
||||||
|
|
||||||
## How matching works
|
## Auto-allow skill CLIs
|
||||||
|
|
||||||
1) Parse the command to determine the executable (first token).
|
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
|
||||||
2) Resolve the executable to an absolute path using `PATH`.
|
are treated as allowlisted (node hosts only). Disable this if you want strict
|
||||||
3) Match against denylist (if present) → **deny**.
|
manual allowlists.
|
||||||
4) Match against allowlist → **allow**.
|
|
||||||
5) Otherwise follow the Exec approvals policy (deny/ask/allow).
|
|
||||||
|
|
||||||
If **auto-allow skill CLIs** is enabled, each installed skill can contribute one
|
|
||||||
or more allowlist entries. A skill-based allowlist entry only auto-allows when:
|
|
||||||
- the resolved path matches, and
|
|
||||||
- the binary hash/version matches the last approved record (if tracked).
|
|
||||||
|
|
||||||
If the binary changes (new hash/version), the command falls back to **Ask** so
|
|
||||||
the user can re-approve.
|
|
||||||
|
|
||||||
## Approval flow
|
## Approval flow
|
||||||
|
|
||||||
When the policy is **Always ask** (or when a binary has changed), the macOS app
|
When a prompt is required, the companion app displays a confirmation dialog with:
|
||||||
shows a confirmation dialog. The dialog should include:
|
|
||||||
- command + args
|
- command + args
|
||||||
- cwd
|
- cwd
|
||||||
- environment overrides (diff)
|
- agent id
|
||||||
- policy + rule that matched (if any)
|
- resolved executable path
|
||||||
|
- host + policy metadata
|
||||||
|
|
||||||
Actions:
|
Actions:
|
||||||
- **Allow once** → run now
|
- **Allow once** → run now
|
||||||
- **Always allow** → add/update allowlist entry + run
|
- **Always allow** → add to allowlist + run
|
||||||
- **Deny** → block
|
- **Deny** → block
|
||||||
|
|
||||||
When approved, the command runs **in the background** and the agent receives
|
|
||||||
system events as it starts and completes.
|
|
||||||
|
|
||||||
## System events
|
## System events
|
||||||
|
|
||||||
The agent receives system messages for observability and recovery:
|
Exec lifecycle is surfaced as system messages:
|
||||||
|
- `exec.started`
|
||||||
|
- `exec.finished`
|
||||||
|
- `exec.denied`
|
||||||
|
|
||||||
- `exec.started` — command accepted and launched
|
These are posted to the agent’s session after the node reports the event.
|
||||||
- `exec.finished` — command completed (exit code + output)
|
|
||||||
- `exec.denied` — command blocked (policy or denylist)
|
|
||||||
|
|
||||||
These are **system messages**; no extra agent tool call is required to resume.
|
|
||||||
|
|
||||||
## Implications
|
## Implications
|
||||||
|
|
||||||
- **Always allow** is powerful: the agent can run any host command without a
|
- **full** is powerful; prefer allowlists when possible.
|
||||||
prompt. Prefer allowlisting trusted CLIs instead.
|
- **ask** keeps you in the loop while still allowing fast approvals.
|
||||||
- **Ask** keeps you in the loop while still allowing fast approvals.
|
- Per-agent allowlists prevent one agent’s approvals from leaking into others.
|
||||||
- Per-agent allowlists prevent one agent’s approval set from leaking into others.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
Allowlists and approval settings are stored **locally in the macOS app** (SQLite
|
|
||||||
is a good fit). The Markdown docs describe behavior; they are not the storage
|
|
||||||
mechanism.
|
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- [Exec tool](/tools/exec)
|
- [Exec tool](/tools/exec)
|
||||||
|
|||||||
+29
-4
@@ -14,21 +14,46 @@ Background sessions are scoped per agent; `process` only sees sessions from the
|
|||||||
## Parameters
|
## Parameters
|
||||||
|
|
||||||
- `command` (required)
|
- `command` (required)
|
||||||
|
- `workdir` (defaults to cwd)
|
||||||
|
- `env` (key/value overrides)
|
||||||
- `yieldMs` (default 10000): auto-background after delay
|
- `yieldMs` (default 10000): auto-background after delay
|
||||||
- `background` (bool): background immediately
|
- `background` (bool): background immediately
|
||||||
- `timeout` (seconds, default 1800): kill on expiry
|
- `timeout` (seconds, default 1800): kill on expiry
|
||||||
- `pty` (bool): run in a pseudo-terminal when available (TTY-only CLIs, coding agents, terminal UIs)
|
- `pty` (bool): run in a pseudo-terminal when available (TTY-only CLIs, coding agents, terminal UIs)
|
||||||
- `elevated` (bool): run on host if elevated mode is enabled/allowed (only changes behavior when the agent is sandboxed)
|
- `host` (`sandbox | gateway | node`): where to execute
|
||||||
- Need a fully interactive session? Use `pty: true` and the `process` tool for stdin/output.
|
- `security` (`deny | allowlist | full`): enforcement mode for `gateway`/`node`
|
||||||
Note: `elevated` is ignored when sandboxing is off (exec already runs on the host).
|
- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node`
|
||||||
|
- `node` (string): node id/name for `host=node`
|
||||||
|
- `elevated` (bool): alias for `host=gateway` + `security=full` when sandboxed and allowed
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `host` defaults to `sandbox`.
|
||||||
|
- `elevated` is ignored when sandboxing is off (exec already runs on the host).
|
||||||
|
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
|
||||||
|
- `node` requires a paired node (macOS companion app).
|
||||||
|
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
||||||
|
- `tools.exec.host` (default: `sandbox`)
|
||||||
|
- `tools.exec.security` (default: `deny`)
|
||||||
|
- `tools.exec.ask` (default: `on-miss`)
|
||||||
|
- `tools.exec.node` (default: unset)
|
||||||
|
|
||||||
|
## Session overrides (`/exec`)
|
||||||
|
|
||||||
|
Use `/exec` to set **per-session** defaults for `host`, `security`, `ask`, and `node`.
|
||||||
|
Send `/exec` with no arguments to show the current values.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
/exec host=gateway security=allowlist ask=on-miss node=mac-1
|
||||||
|
```
|
||||||
|
|
||||||
## Exec approvals (macOS app)
|
## Exec approvals (macOS app)
|
||||||
|
|
||||||
Sandboxed agents can require per-request approval before `exec` runs on the host.
|
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
|
||||||
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|||||||
+6
-2
@@ -169,15 +169,19 @@ Core parameters:
|
|||||||
- `background` (immediate background)
|
- `background` (immediate background)
|
||||||
- `timeout` (seconds; kills the process if exceeded, default 1800)
|
- `timeout` (seconds; kills the process if exceeded, default 1800)
|
||||||
- `elevated` (bool; run on host if elevated mode is enabled/allowed; only changes behavior when the agent is sandboxed)
|
- `elevated` (bool; run on host if elevated mode is enabled/allowed; only changes behavior when the agent is sandboxed)
|
||||||
|
- `host` (`sandbox | gateway | node`)
|
||||||
|
- `security` (`deny | allowlist | full`)
|
||||||
|
- `ask` (`off | on-miss | always`)
|
||||||
|
- `node` (node id/name for `host=node`)
|
||||||
- Need a real TTY? Set `pty: true`.
|
- Need a real TTY? Set `pty: true`.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Returns `status: "running"` with a `sessionId` when backgrounded.
|
- Returns `status: "running"` with a `sessionId` when backgrounded.
|
||||||
- Use `process` to poll/log/write/kill/clear background sessions.
|
- Use `process` to poll/log/write/kill/clear background sessions.
|
||||||
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
||||||
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and runs on the host.
|
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
|
||||||
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
||||||
- macOS app approvals/allowlists: [Exec approvals](/tools/exec-approvals).
|
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
|
||||||
|
|
||||||
### `process`
|
### `process`
|
||||||
Manage background exec sessions.
|
Manage background exec sessions.
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ The host-only bash chat command uses `! <cmd>` (with `/bash <cmd>` as an alias).
|
|||||||
There are two related systems:
|
There are two related systems:
|
||||||
|
|
||||||
- **Commands**: standalone `/...` messages.
|
- **Commands**: standalone `/...` messages.
|
||||||
- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/model`, `/queue`.
|
- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/exec`, `/model`, `/queue`.
|
||||||
- Directives are stripped from the message before the model sees it.
|
- Directives are stripped from the message before the model sees it.
|
||||||
- In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings.
|
- In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings.
|
||||||
- In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement.
|
- In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement.
|
||||||
|
|
||||||
There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`).
|
There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status`, `/whoami` (`/id`).
|
||||||
They run immediately, are stripped before the model sees the message, and the remaining text continues through the normal flow.
|
They run immediately, are stripped before the model sees the message, and the remaining text continues through the normal flow.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
@@ -60,11 +60,11 @@ Text + native (when enabled):
|
|||||||
- `/commands`
|
- `/commands`
|
||||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
||||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||||
- `/usage` (alias: `/status`)
|
|
||||||
- `/whoami` (show your sender id; alias: `/id`)
|
- `/whoami` (show your sender id; alias: `/id`)
|
||||||
|
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
||||||
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
||||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||||
- `/cost on|off` (toggle per-response usage line)
|
- `/usage off|tokens|full` (per-response usage footer)
|
||||||
- `/stop`
|
- `/stop`
|
||||||
- `/restart`
|
- `/restart`
|
||||||
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
|
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
|
||||||
@@ -77,6 +77,7 @@ Text + native (when enabled):
|
|||||||
- `/verbose on|full|off` (alias: `/v`)
|
- `/verbose on|full|off` (alias: `/v`)
|
||||||
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
|
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
|
||||||
- `/elevated on|off` (alias: `/elev`)
|
- `/elevated on|off` (alias: `/elev`)
|
||||||
|
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current)
|
||||||
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
|
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
|
||||||
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
|
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
|
||||||
- `/bash <command>` (host-only; alias for `! <command>`; requires `commands.bash: true` + `tools.elevated` allowlists)
|
- `/bash <command>` (host-only; alias for `! <command>`; requires `commands.bash: true` + `tools.elevated` allowlists)
|
||||||
@@ -89,8 +90,8 @@ Text-only:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
|
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
|
||||||
- `/status` and `/usage` show the same status output; for full provider usage breakdown, use `clawdbot status --usage`.
|
- For full provider usage breakdown, use `clawdbot status --usage`.
|
||||||
- `/cost` appends per-response token usage; it only shows dollar cost when the model uses an API key (OAuth hides cost).
|
- `/usage` controls the per-response usage footer. It only shows dollar cost when the model uses an API key (OAuth hides cost).
|
||||||
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
||||||
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
||||||
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
|
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
|
||||||
@@ -98,15 +99,15 @@ Notes:
|
|||||||
- **Group mention gating:** command-only messages from allowlisted senders bypass mention requirements.
|
- **Group mention gating:** command-only messages from allowlisted senders bypass mention requirements.
|
||||||
- **Inline shortcuts (allowlisted senders only):** certain commands also work when embedded in a normal message and are stripped before the model sees the remaining text.
|
- **Inline shortcuts (allowlisted senders only):** certain commands also work when embedded in a normal message and are stripped before the model sees the remaining text.
|
||||||
- Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow.
|
- Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow.
|
||||||
- Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`).
|
- Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`).
|
||||||
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
|
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
|
||||||
- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`).
|
- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`).
|
||||||
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
|
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
|
||||||
|
|
||||||
## Usage vs cost (what shows where)
|
## Usage surfaces (what shows where)
|
||||||
|
|
||||||
- **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` for the current model provider when usage tracking is enabled.
|
- **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` for the current model provider when usage tracking is enabled.
|
||||||
- **Per-response tokens/cost** is controlled by `/cost on|off` (appended to normal replies).
|
- **Per-response tokens/cost** is controlled by `/usage off|tokens|full` (appended to normal replies).
|
||||||
- `/model status` is about **models/auth/endpoints**, not usage.
|
- `/model status` is about **models/auth/endpoints**, not usage.
|
||||||
|
|
||||||
## Model selection (`/model`)
|
## Model selection (`/model`)
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ read_when:
|
|||||||
|
|
||||||
Sub-agents are background agent runs spawned from an existing agent run. They run in their own session (`agent:<agentId>:subagent:<uuid>`) and, when finished, **announce** their result back to the requester chat channel.
|
Sub-agents are background agent runs spawned from an existing agent run. They run in their own session (`agent:<agentId>:subagent:<uuid>`) and, when finished, **announce** their result back to the requester chat channel.
|
||||||
|
|
||||||
|
## Slash command
|
||||||
|
|
||||||
|
Use `/subagents` to inspect or control sub-agent runs for the **current session**:
|
||||||
|
- `/subagents list`
|
||||||
|
- `/subagents stop <id|#|all>`
|
||||||
|
- `/subagents log <id|#> [limit] [tools]`
|
||||||
|
- `/subagents info <id|#>`
|
||||||
|
- `/subagents send <id|#> <message>`
|
||||||
|
|
||||||
|
`/subagents info` shows run metadata (status, timestamps, session id, transcript path, cleanup).
|
||||||
|
|
||||||
Primary goals:
|
Primary goals:
|
||||||
- Parallelize “research / long task / slow tool” work without blocking the main run.
|
- Parallelize “research / long task / slow tool” work without blocking the main run.
|
||||||
- Keep sub-agents isolated by default (session separation + optional sandboxing).
|
- Keep sub-agents isolated by default (session separation + optional sandboxing).
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ Session controls:
|
|||||||
- `/think <off|minimal|low|medium|high>`
|
- `/think <off|minimal|low|medium|high>`
|
||||||
- `/verbose <on|full|off>`
|
- `/verbose <on|full|off>`
|
||||||
- `/reasoning <on|off|stream>`
|
- `/reasoning <on|off|stream>`
|
||||||
- `/cost <on|off>`
|
- `/usage <off|tokens|full>`
|
||||||
- `/elevated <on|off>` (alias: `/elev`)
|
- `/elevated <on|off>` (alias: `/elev`)
|
||||||
- `/activation <mention|always>`
|
- `/activation <mention|always>`
|
||||||
- `/deliver <on|off>`
|
- `/deliver <on|off>`
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { bluebubblesPlugin } from "./src/channel.js";
|
||||||
|
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
|
||||||
|
import { setBlueBubblesRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "bluebubbles",
|
||||||
|
name: "BlueBubbles",
|
||||||
|
description: "BlueBubbles channel plugin (macOS app)",
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setBlueBubblesRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: bluebubblesPlugin });
|
||||||
|
api.registerHttpHandler(handleBlueBubblesWebhookRequest);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/bluebubbles",
|
||||||
|
"version": "2026.1.15",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot BlueBubbles channel plugin",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": ["./index.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||||
|
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
|
||||||
|
|
||||||
|
export type ResolvedBlueBubblesAccount = {
|
||||||
|
accountId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
name?: string;
|
||||||
|
config: BlueBubblesAccountConfig;
|
||||||
|
configured: boolean;
|
||||||
|
baseUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||||
|
const accounts = cfg.channels?.bluebubbles?.accounts;
|
||||||
|
if (!accounts || typeof accounts !== "object") return [];
|
||||||
|
return Object.keys(accounts).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBlueBubblesAccountIds(cfg: ClawdbotConfig): string[] {
|
||||||
|
const ids = listConfiguredAccountIds(cfg);
|
||||||
|
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
||||||
|
return ids.sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultBlueBubblesAccountId(cfg: ClawdbotConfig): string {
|
||||||
|
const ids = listBlueBubblesAccountIds(cfg);
|
||||||
|
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||||
|
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAccountConfig(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
accountId: string,
|
||||||
|
): BlueBubblesAccountConfig | undefined {
|
||||||
|
const accounts = cfg.channels?.bluebubbles?.accounts;
|
||||||
|
if (!accounts || typeof accounts !== "object") return undefined;
|
||||||
|
return accounts[accountId] as BlueBubblesAccountConfig | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeBlueBubblesAccountConfig(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
accountId: string,
|
||||||
|
): BlueBubblesAccountConfig {
|
||||||
|
const base = (cfg.channels?.bluebubbles ?? {}) as BlueBubblesAccountConfig & {
|
||||||
|
accounts?: unknown;
|
||||||
|
};
|
||||||
|
const { accounts: _ignored, ...rest } = base;
|
||||||
|
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||||
|
return { ...rest, ...account };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBlueBubblesAccount(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
}): ResolvedBlueBubblesAccount {
|
||||||
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
|
const baseEnabled = params.cfg.channels?.bluebubbles?.enabled;
|
||||||
|
const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId);
|
||||||
|
const accountEnabled = merged.enabled !== false;
|
||||||
|
const serverUrl = merged.serverUrl?.trim();
|
||||||
|
const password = merged.password?.trim();
|
||||||
|
const configured = Boolean(serverUrl && password);
|
||||||
|
const baseUrl = serverUrl ? normalizeBlueBubblesServerUrl(serverUrl) : undefined;
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
enabled: baseEnabled !== false && accountEnabled,
|
||||||
|
name: merged.name?.trim() || undefined,
|
||||||
|
config: merged,
|
||||||
|
configured,
|
||||||
|
baseUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listEnabledBlueBubblesAccounts(cfg: ClawdbotConfig): ResolvedBlueBubblesAccount[] {
|
||||||
|
return listBlueBubblesAccountIds(cfg)
|
||||||
|
.map((accountId) => resolveBlueBubblesAccount({ cfg, accountId }))
|
||||||
|
.filter((account) => account.enabled);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
createActionGate,
|
||||||
|
jsonResult,
|
||||||
|
readNumberParam,
|
||||||
|
readReactionParams,
|
||||||
|
readStringParam,
|
||||||
|
type ChannelMessageActionAdapter,
|
||||||
|
type ChannelMessageActionName,
|
||||||
|
type ChannelToolSend,
|
||||||
|
type ClawdbotConfig,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
|
import { sendBlueBubblesReaction } from "./reactions.js";
|
||||||
|
import { resolveChatGuidForTarget } from "./send.js";
|
||||||
|
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
||||||
|
import type { BlueBubblesSendTarget } from "./types.js";
|
||||||
|
|
||||||
|
const providerId = "bluebubbles";
|
||||||
|
|
||||||
|
function mapTarget(raw: string): BlueBubblesSendTarget {
|
||||||
|
const parsed = parseBlueBubblesTarget(raw);
|
||||||
|
if (parsed.kind === "chat_guid") return { kind: "chat_guid", chatGuid: parsed.chatGuid };
|
||||||
|
if (parsed.kind === "chat_id") return { kind: "chat_id", chatId: parsed.chatId };
|
||||||
|
if (parsed.kind === "chat_identifier") {
|
||||||
|
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "handle",
|
||||||
|
address: normalizeBlueBubblesHandle(parsed.to),
|
||||||
|
service: parsed.service,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||||
|
listActions: ({ cfg }) => {
|
||||||
|
const account = resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig });
|
||||||
|
if (!account.enabled || !account.configured) return [];
|
||||||
|
const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions);
|
||||||
|
const actions = new Set<ChannelMessageActionName>();
|
||||||
|
if (gate("reactions")) actions.add("react");
|
||||||
|
return Array.from(actions);
|
||||||
|
},
|
||||||
|
supportsAction: ({ action }) => action === "react",
|
||||||
|
extractToolSend: ({ args }): ChannelToolSend | null => {
|
||||||
|
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 !== "react") {
|
||||||
|
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||||
|
}
|
||||||
|
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
||||||
|
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
|
||||||
|
});
|
||||||
|
if (isEmpty && !remove) {
|
||||||
|
throw new Error("Emoji is required to send a BlueBubbles reaction.");
|
||||||
|
}
|
||||||
|
const messageId = readStringParam(params, "messageId", { required: true });
|
||||||
|
const chatGuid = readStringParam(params, "chatGuid");
|
||||||
|
const chatIdentifier = readStringParam(params, "chatIdentifier");
|
||||||
|
const chatId = readNumberParam(params, "chatId", { integer: true });
|
||||||
|
const to = readStringParam(params, "to");
|
||||||
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
|
|
||||||
|
const account = resolveBlueBubblesAccount({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
const baseUrl = account.config.serverUrl?.trim();
|
||||||
|
const password = account.config.password?.trim();
|
||||||
|
|
||||||
|
let resolvedChatGuid = chatGuid?.trim() || "";
|
||||||
|
if (!resolvedChatGuid) {
|
||||||
|
const target =
|
||||||
|
chatIdentifier?.trim()
|
||||||
|
? ({ kind: "chat_identifier", chatIdentifier: chatIdentifier.trim() } as BlueBubblesSendTarget)
|
||||||
|
: typeof chatId === "number"
|
||||||
|
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
|
||||||
|
: to
|
||||||
|
? mapTarget(to)
|
||||||
|
: null;
|
||||||
|
if (!target) {
|
||||||
|
throw new Error("BlueBubbles reaction requires chatGuid, chatIdentifier, chatId, or to.");
|
||||||
|
}
|
||||||
|
if (!baseUrl || !password) {
|
||||||
|
throw new Error("BlueBubbles reaction requires serverUrl and password.");
|
||||||
|
}
|
||||||
|
resolvedChatGuid =
|
||||||
|
(await resolveChatGuidForTarget({
|
||||||
|
baseUrl,
|
||||||
|
password,
|
||||||
|
target,
|
||||||
|
})) ?? "";
|
||||||
|
}
|
||||||
|
if (!resolvedChatGuid) {
|
||||||
|
throw new Error("BlueBubbles reaction failed: chatGuid not found for target.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendBlueBubblesReaction({
|
||||||
|
chatGuid: resolvedChatGuid,
|
||||||
|
messageGuid: messageId,
|
||||||
|
emoji,
|
||||||
|
remove: remove || undefined,
|
||||||
|
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||||
|
opts: {
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!remove) {
|
||||||
|
return jsonResult({ ok: true, added: emoji });
|
||||||
|
}
|
||||||
|
return jsonResult({ ok: true, removed: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
|
import {
|
||||||
|
blueBubblesFetchWithTimeout,
|
||||||
|
buildBlueBubblesApiUrl,
|
||||||
|
type BlueBubblesAttachment,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export type BlueBubblesAttachmentOpts = {
|
||||||
|
serverUrl?: string;
|
||||||
|
password?: string;
|
||||||
|
accountId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
cfg?: ClawdbotConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
||||||
|
const account = resolveBlueBubblesAccount({
|
||||||
|
cfg: params.cfg ?? {},
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||||
|
const password = params.password?.trim() || account.config.password?.trim();
|
||||||
|
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
|
||||||
|
if (!password) throw new Error("BlueBubbles password is required");
|
||||||
|
return { baseUrl, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadBlueBubblesAttachment(
|
||||||
|
attachment: BlueBubblesAttachment,
|
||||||
|
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
|
||||||
|
): Promise<{ buffer: Uint8Array; contentType?: string }> {
|
||||||
|
const guid = attachment.guid?.trim();
|
||||||
|
if (!guid) throw new Error("BlueBubbles attachment guid is required");
|
||||||
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
baseUrl,
|
||||||
|
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`BlueBubbles attachment download failed (${res.status}): ${errorText || "unknown"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const contentType = res.headers.get("content-type") ?? undefined;
|
||||||
|
const buf = new Uint8Array(await res.arrayBuffer());
|
||||||
|
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
|
||||||
|
if (buf.byteLength > maxBytes) {
|
||||||
|
throw new Error(`BlueBubbles attachment too large (${buf.byteLength} bytes)`);
|
||||||
|
}
|
||||||
|
return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import type { ChannelAccountSnapshot, ChannelPlugin, ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import {
|
||||||
|
applyAccountNameToChannelSection,
|
||||||
|
buildChannelConfigSchema,
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
deleteAccountFromConfigSection,
|
||||||
|
formatPairingApproveHint,
|
||||||
|
migrateBaseNameToDefaultAccount,
|
||||||
|
normalizeAccountId,
|
||||||
|
PAIRING_APPROVED_MESSAGE,
|
||||||
|
setAccountEnabledInConfigSection,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import {
|
||||||
|
listBlueBubblesAccountIds,
|
||||||
|
type ResolvedBlueBubblesAccount,
|
||||||
|
resolveBlueBubblesAccount,
|
||||||
|
resolveDefaultBlueBubblesAccountId,
|
||||||
|
} from "./accounts.js";
|
||||||
|
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||||
|
import { probeBlueBubbles } from "./probe.js";
|
||||||
|
import { sendMessageBlueBubbles } from "./send.js";
|
||||||
|
import { normalizeBlueBubblesHandle } from "./targets.js";
|
||||||
|
import { bluebubblesMessageActions } from "./actions.js";
|
||||||
|
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
id: "bluebubbles",
|
||||||
|
label: "BlueBubbles",
|
||||||
|
selectionLabel: "BlueBubbles (macOS app)",
|
||||||
|
docsPath: "/channels/bluebubbles",
|
||||||
|
docsLabel: "bluebubbles",
|
||||||
|
blurb: "iMessage via the BlueBubbles mac app + REST API.",
|
||||||
|
order: 75,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||||
|
id: "bluebubbles",
|
||||||
|
meta,
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct", "group"],
|
||||||
|
media: false,
|
||||||
|
reactions: true,
|
||||||
|
},
|
||||||
|
reload: { configPrefixes: ["channels.bluebubbles"] },
|
||||||
|
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
||||||
|
config: {
|
||||||
|
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg as ClawdbotConfig),
|
||||||
|
resolveAccount: (cfg, accountId) =>
|
||||||
|
resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig, accountId }),
|
||||||
|
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg as ClawdbotConfig),
|
||||||
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||||
|
setAccountEnabledInConfigSection({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
sectionKey: "bluebubbles",
|
||||||
|
accountId,
|
||||||
|
enabled,
|
||||||
|
allowTopLevel: true,
|
||||||
|
}),
|
||||||
|
deleteAccount: ({ cfg, accountId }) =>
|
||||||
|
deleteAccountFromConfigSection({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
sectionKey: "bluebubbles",
|
||||||
|
accountId,
|
||||||
|
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
|
||||||
|
}),
|
||||||
|
isConfigured: (account) => account.configured,
|
||||||
|
describeAccount: (account): ChannelAccountSnapshot => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: account.configured,
|
||||||
|
baseUrl: account.baseUrl,
|
||||||
|
}),
|
||||||
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
|
(resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ??
|
||||||
|
[]).map(
|
||||||
|
(entry) => String(entry),
|
||||||
|
),
|
||||||
|
formatAllowFrom: ({ allowFrom }) =>
|
||||||
|
allowFrom
|
||||||
|
.map((entry) => String(entry).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((entry) => entry.replace(/^bluebubbles:/i, ""))
|
||||||
|
.map((entry) => normalizeBlueBubblesHandle(entry)),
|
||||||
|
},
|
||||||
|
actions: bluebubblesMessageActions,
|
||||||
|
security: {
|
||||||
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||||
|
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
const useAccountPath = Boolean(
|
||||||
|
(cfg as ClawdbotConfig).channels?.bluebubbles?.accounts?.[resolvedAccountId],
|
||||||
|
);
|
||||||
|
const basePath = useAccountPath
|
||||||
|
? `channels.bluebubbles.accounts.${resolvedAccountId}.`
|
||||||
|
: "channels.bluebubbles.";
|
||||||
|
return {
|
||||||
|
policy: account.config.dmPolicy ?? "pairing",
|
||||||
|
allowFrom: account.config.allowFrom ?? [],
|
||||||
|
policyPath: `${basePath}dmPolicy`,
|
||||||
|
allowFromPath: basePath,
|
||||||
|
approveHint: formatPairingApproveHint("bluebubbles"),
|
||||||
|
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
collectWarnings: ({ account }) => {
|
||||||
|
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
||||||
|
if (groupPolicy !== "open") return [];
|
||||||
|
return [
|
||||||
|
`- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup: {
|
||||||
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||||
|
applyAccountName: ({ cfg, accountId, name }) =>
|
||||||
|
applyAccountNameToChannelSection({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
channelKey: "bluebubbles",
|
||||||
|
accountId,
|
||||||
|
name,
|
||||||
|
}),
|
||||||
|
validateInput: ({ input }) => {
|
||||||
|
if (!input.httpUrl && !input.password) {
|
||||||
|
return "BlueBubbles requires --http-url and --password.";
|
||||||
|
}
|
||||||
|
if (!input.httpUrl) return "BlueBubbles requires --http-url.";
|
||||||
|
if (!input.password) return "BlueBubbles requires --password.";
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||||
|
const namedConfig = applyAccountNameToChannelSection({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
channelKey: "bluebubbles",
|
||||||
|
accountId,
|
||||||
|
name: input.name,
|
||||||
|
});
|
||||||
|
const next =
|
||||||
|
accountId !== DEFAULT_ACCOUNT_ID
|
||||||
|
? migrateBaseNameToDefaultAccount({
|
||||||
|
cfg: namedConfig,
|
||||||
|
channelKey: "bluebubbles",
|
||||||
|
})
|
||||||
|
: namedConfig;
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
channels: {
|
||||||
|
...next.channels,
|
||||||
|
bluebubbles: {
|
||||||
|
...next.channels?.bluebubbles,
|
||||||
|
enabled: true,
|
||||||
|
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
||||||
|
...(input.password ? { password: input.password } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
channels: {
|
||||||
|
...next.channels,
|
||||||
|
bluebubbles: {
|
||||||
|
...next.channels?.bluebubbles,
|
||||||
|
enabled: true,
|
||||||
|
accounts: {
|
||||||
|
...(next.channels?.bluebubbles?.accounts ?? {}),
|
||||||
|
[accountId]: {
|
||||||
|
...(next.channels?.bluebubbles?.accounts?.[accountId] ?? {}),
|
||||||
|
enabled: true,
|
||||||
|
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
||||||
|
...(input.password ? { password: input.password } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pairing: {
|
||||||
|
idLabel: "bluebubblesSenderId",
|
||||||
|
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
||||||
|
notifyApproval: async ({ cfg, id }) => {
|
||||||
|
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
textChunkLimit: 4000,
|
||||||
|
resolveTarget: ({ to }) => {
|
||||||
|
const trimmed = to?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: new Error("Delivering to BlueBubbles requires --to <handle|chat_guid:GUID>"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, to: trimmed };
|
||||||
|
},
|
||||||
|
sendText: async ({ cfg, to, text, accountId }) => {
|
||||||
|
const result = await sendMessageBlueBubbles(to, text, {
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
return { channel: "bluebubbles", ...result };
|
||||||
|
},
|
||||||
|
sendMedia: async () => {
|
||||||
|
throw new Error("BlueBubbles media delivery is not supported yet.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
defaultRuntime: {
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
running: false,
|
||||||
|
lastStartAt: null,
|
||||||
|
lastStopAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
collectStatusIssues: (accounts) =>
|
||||||
|
accounts.flatMap((account) => {
|
||||||
|
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
||||||
|
if (!lastError) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
channel: "bluebubbles",
|
||||||
|
accountId: account.accountId,
|
||||||
|
kind: "runtime",
|
||||||
|
message: `Channel error: ${lastError}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
buildChannelSummary: ({ snapshot }) => ({
|
||||||
|
configured: snapshot.configured ?? false,
|
||||||
|
baseUrl: snapshot.baseUrl ?? null,
|
||||||
|
running: snapshot.running ?? false,
|
||||||
|
lastStartAt: snapshot.lastStartAt ?? null,
|
||||||
|
lastStopAt: snapshot.lastStopAt ?? null,
|
||||||
|
lastError: snapshot.lastError ?? null,
|
||||||
|
probe: snapshot.probe,
|
||||||
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
|
}),
|
||||||
|
probeAccount: async ({ account, timeoutMs }) =>
|
||||||
|
probeBlueBubbles({
|
||||||
|
baseUrl: account.baseUrl,
|
||||||
|
password: account.config.password ?? null,
|
||||||
|
timeoutMs,
|
||||||
|
}),
|
||||||
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: account.configured,
|
||||||
|
baseUrl: account.baseUrl,
|
||||||
|
running: runtime?.running ?? false,
|
||||||
|
lastStartAt: runtime?.lastStartAt ?? null,
|
||||||
|
lastStopAt: runtime?.lastStopAt ?? null,
|
||||||
|
lastError: runtime?.lastError ?? null,
|
||||||
|
probe,
|
||||||
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||||
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
startAccount: async (ctx) => {
|
||||||
|
const account = ctx.account;
|
||||||
|
const webhookPath = resolveWebhookPathFromConfig(account.config);
|
||||||
|
ctx.setStatus({
|
||||||
|
accountId: account.accountId,
|
||||||
|
baseUrl: account.baseUrl,
|
||||||
|
});
|
||||||
|
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
|
||||||
|
return monitorBlueBubblesProvider({
|
||||||
|
account,
|
||||||
|
config: ctx.cfg as ClawdbotConfig,
|
||||||
|
runtime: ctx.runtime,
|
||||||
|
abortSignal: ctx.abortSignal,
|
||||||
|
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||||
|
webhookPath,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||||
|
|
||||||
|
export type BlueBubblesChatOpts = {
|
||||||
|
serverUrl?: string;
|
||||||
|
password?: string;
|
||||||
|
accountId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
cfg?: ClawdbotConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveAccount(params: BlueBubblesChatOpts) {
|
||||||
|
const account = resolveBlueBubblesAccount({
|
||||||
|
cfg: params.cfg ?? {},
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||||
|
const password = params.password?.trim() || account.config.password?.trim();
|
||||||
|
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
|
||||||
|
if (!password) throw new Error("BlueBubbles password is required");
|
||||||
|
return { baseUrl, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markBlueBubblesChatRead(
|
||||||
|
chatGuid: string,
|
||||||
|
opts: BlueBubblesChatOpts = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const trimmed = chatGuid.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
baseUrl,
|
||||||
|
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text().catch(() => "");
|
||||||
|
throw new Error(`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendBlueBubblesTyping(
|
||||||
|
chatGuid: string,
|
||||||
|
typing: boolean,
|
||||||
|
opts: BlueBubblesChatOpts = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const trimmed = chatGuid.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
baseUrl,
|
||||||
|
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
const res = await blueBubblesFetchWithTimeout(
|
||||||
|
url,
|
||||||
|
{ method: typing ? "POST" : "DELETE" },
|
||||||
|
opts.timeoutMs,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text().catch(() => "");
|
||||||
|
throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
|
|
||||||
|
const bluebubblesActionSchema = z
|
||||||
|
.object({
|
||||||
|
reactions: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
const bluebubblesAccountSchema = z.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
serverUrl: z.string().optional(),
|
||||||
|
password: z.string().optional(),
|
||||||
|
webhookPath: z.string().optional(),
|
||||||
|
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||||
|
allowFrom: z.array(allowFromEntry).optional(),
|
||||||
|
groupAllowFrom: z.array(allowFromEntry).optional(),
|
||||||
|
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
|
||||||
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
mediaMaxMb: z.number().int().positive().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
|
||||||
|
accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
|
||||||
|
actions: bluebubblesActionSchema,
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
|||||||
|
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
|
||||||
|
|
||||||
|
export type BlueBubblesProbe = {
|
||||||
|
ok: boolean;
|
||||||
|
status?: number | null;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function probeBlueBubbles(params: {
|
||||||
|
baseUrl?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<BlueBubblesProbe> {
|
||||||
|
const baseUrl = params.baseUrl?.trim();
|
||||||
|
const password = params.password?.trim();
|
||||||
|
if (!baseUrl) return { ok: false, error: "serverUrl not configured" };
|
||||||
|
if (!password) return { ok: false, error: "password not configured" };
|
||||||
|
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password });
|
||||||
|
try {
|
||||||
|
const res = await blueBubblesFetchWithTimeout(
|
||||||
|
url,
|
||||||
|
{ method: "GET" },
|
||||||
|
params.timeoutMs,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
|
||||||
|
}
|
||||||
|
return { ok: true, status: res.status };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: null,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||||
|
|
||||||
|
export type BlueBubblesReactionOpts = {
|
||||||
|
serverUrl?: string;
|
||||||
|
password?: string;
|
||||||
|
accountId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
cfg?: ClawdbotConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const REACTION_TYPES = new Set([
|
||||||
|
"love",
|
||||||
|
"like",
|
||||||
|
"dislike",
|
||||||
|
"laugh",
|
||||||
|
"emphasize",
|
||||||
|
"question",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REACTION_ALIASES = new Map<string, string>([
|
||||||
|
["heart", "love"],
|
||||||
|
["thumbs_up", "like"],
|
||||||
|
["thumbs-down", "dislike"],
|
||||||
|
["thumbs_down", "dislike"],
|
||||||
|
["haha", "laugh"],
|
||||||
|
["lol", "laugh"],
|
||||||
|
["emphasis", "emphasize"],
|
||||||
|
["exclaim", "emphasize"],
|
||||||
|
["question", "question"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REACTION_EMOJIS = new Map<string, string>([
|
||||||
|
["❤️", "love"],
|
||||||
|
["❤", "love"],
|
||||||
|
["♥️", "love"],
|
||||||
|
["😍", "love"],
|
||||||
|
["👍", "like"],
|
||||||
|
["👎", "dislike"],
|
||||||
|
["😂", "laugh"],
|
||||||
|
["🤣", "laugh"],
|
||||||
|
["😆", "laugh"],
|
||||||
|
["‼️", "emphasize"],
|
||||||
|
["‼", "emphasize"],
|
||||||
|
["❗", "emphasize"],
|
||||||
|
["❓", "question"],
|
||||||
|
["❔", "question"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function resolveAccount(params: BlueBubblesReactionOpts) {
|
||||||
|
const account = resolveBlueBubblesAccount({
|
||||||
|
cfg: params.cfg ?? {},
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||||
|
const password = params.password?.trim() || account.config.password?.trim();
|
||||||
|
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
|
||||||
|
if (!password) throw new Error("BlueBubbles password is required");
|
||||||
|
return { baseUrl, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReactionInput(emoji: string, remove?: boolean): string {
|
||||||
|
const trimmed = emoji.trim();
|
||||||
|
if (!trimmed) throw new Error("BlueBubbles reaction requires an emoji or name.");
|
||||||
|
let raw = trimmed.toLowerCase();
|
||||||
|
if (raw.startsWith("-")) raw = raw.slice(1);
|
||||||
|
const aliased = REACTION_ALIASES.get(raw) ?? raw;
|
||||||
|
const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased;
|
||||||
|
if (!REACTION_TYPES.has(mapped)) {
|
||||||
|
throw new Error(`Unsupported BlueBubbles reaction: ${trimmed}`);
|
||||||
|
}
|
||||||
|
return remove ? `-${mapped}` : mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendBlueBubblesReaction(params: {
|
||||||
|
chatGuid: string;
|
||||||
|
messageGuid: string;
|
||||||
|
emoji: string;
|
||||||
|
remove?: boolean;
|
||||||
|
partIndex?: number;
|
||||||
|
opts?: BlueBubblesReactionOpts;
|
||||||
|
}): Promise<void> {
|
||||||
|
const chatGuid = params.chatGuid.trim();
|
||||||
|
const messageGuid = params.messageGuid.trim();
|
||||||
|
if (!chatGuid) throw new Error("BlueBubbles reaction requires chatGuid.");
|
||||||
|
if (!messageGuid) throw new Error("BlueBubbles reaction requires messageGuid.");
|
||||||
|
const reaction = normalizeReactionInput(params.emoji, params.remove);
|
||||||
|
const { baseUrl, password } = resolveAccount(params.opts ?? {});
|
||||||
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
baseUrl,
|
||||||
|
path: "/api/v1/message/react",
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
chatGuid,
|
||||||
|
selectedMessageGuid: messageGuid,
|
||||||
|
reaction,
|
||||||
|
partIndex: typeof params.partIndex === "number" ? params.partIndex : 0,
|
||||||
|
};
|
||||||
|
const res = await blueBubblesFetchWithTimeout(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
params.opts?.timeoutMs,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text();
|
||||||
|
throw new Error(`BlueBubbles reaction failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
export function setBlueBubblesRuntime(next: PluginRuntime): void {
|
||||||
|
runtime = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlueBubblesRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("BlueBubbles runtime not initialized");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
|
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import {
|
||||||
|
blueBubblesFetchWithTimeout,
|
||||||
|
buildBlueBubblesApiUrl,
|
||||||
|
type BlueBubblesSendTarget,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export type BlueBubblesSendOpts = {
|
||||||
|
serverUrl?: string;
|
||||||
|
password?: string;
|
||||||
|
accountId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
cfg?: ClawdbotConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlueBubblesSendResult = {
|
||||||
|
messageId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
|
||||||
|
const parsed = parseBlueBubblesTarget(raw);
|
||||||
|
if (parsed.kind === "handle") {
|
||||||
|
return {
|
||||||
|
kind: "handle",
|
||||||
|
address: normalizeBlueBubblesHandle(parsed.to),
|
||||||
|
service: parsed.service,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (parsed.kind === "chat_id") {
|
||||||
|
return { kind: "chat_id", chatId: parsed.chatId };
|
||||||
|
}
|
||||||
|
if (parsed.kind === "chat_guid") {
|
||||||
|
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
|
||||||
|
}
|
||||||
|
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMessageId(payload: unknown): string {
|
||||||
|
if (!payload || typeof payload !== "object") return "unknown";
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
const data = record.data && typeof record.data === "object" ? (record.data as Record<string, unknown>) : null;
|
||||||
|
const candidates = [
|
||||||
|
record.messageId,
|
||||||
|
record.guid,
|
||||||
|
record.id,
|
||||||
|
data?.messageId,
|
||||||
|
data?.guid,
|
||||||
|
data?.id,
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||||
|
if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate);
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlueBubblesChatRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
|
||||||
|
const candidates = [
|
||||||
|
chat.chatGuid,
|
||||||
|
chat.guid,
|
||||||
|
chat.chat_guid,
|
||||||
|
chat.identifier,
|
||||||
|
chat.chatIdentifier,
|
||||||
|
chat.chat_identifier,
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractChatId(chat: BlueBubblesChatRecord): number | null {
|
||||||
|
const candidates = [chat.chatId, chat.id, chat.chat_id];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === "number" && Number.isFinite(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
|
||||||
|
const raw =
|
||||||
|
(Array.isArray(chat.participants) ? chat.participants : null) ??
|
||||||
|
(Array.isArray(chat.handles) ? chat.handles : null) ??
|
||||||
|
(Array.isArray(chat.participantHandles) ? chat.participantHandles : null);
|
||||||
|
if (!raw) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
out.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry && typeof entry === "object") {
|
||||||
|
const record = entry as Record<string, unknown>;
|
||||||
|
const candidate =
|
||||||
|
(typeof record.address === "string" && record.address) ||
|
||||||
|
(typeof record.handle === "string" && record.handle) ||
|
||||||
|
(typeof record.id === "string" && record.id) ||
|
||||||
|
(typeof record.identifier === "string" && record.identifier);
|
||||||
|
if (candidate) out.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryChats(params: {
|
||||||
|
baseUrl: string;
|
||||||
|
password: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
}): Promise<BlueBubblesChatRecord[]> {
|
||||||
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
baseUrl: params.baseUrl,
|
||||||
|
path: "/api/v1/chat/query",
|
||||||
|
password: params.password,
|
||||||
|
});
|
||||||
|
const res = await blueBubblesFetchWithTimeout(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
limit: params.limit,
|
||||||
|
offset: params.offset,
|
||||||
|
with: ["participants"],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
params.timeoutMs,
|
||||||
|
);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||||||
|
const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null;
|
||||||
|
return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveChatGuidForTarget(params: {
|
||||||
|
baseUrl: string;
|
||||||
|
password: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
target: BlueBubblesSendTarget;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
if (params.target.kind === "chat_guid") return params.target.chatGuid;
|
||||||
|
|
||||||
|
const normalizedHandle =
|
||||||
|
params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : "";
|
||||||
|
const targetChatId = params.target.kind === "chat_id" ? params.target.chatId : null;
|
||||||
|
const targetChatIdentifier =
|
||||||
|
params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null;
|
||||||
|
|
||||||
|
const limit = 500;
|
||||||
|
for (let offset = 0; offset < 5000; offset += limit) {
|
||||||
|
const chats = await queryChats({
|
||||||
|
baseUrl: params.baseUrl,
|
||||||
|
password: params.password,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
if (chats.length === 0) break;
|
||||||
|
for (const chat of chats) {
|
||||||
|
if (targetChatId != null) {
|
||||||
|
const chatId = extractChatId(chat);
|
||||||
|
if (chatId != null && chatId === targetChatId) {
|
||||||
|
return extractChatGuid(chat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetChatIdentifier) {
|
||||||
|
const guid = extractChatGuid(chat);
|
||||||
|
if (guid && guid === targetChatIdentifier) return guid;
|
||||||
|
const identifier =
|
||||||
|
typeof chat.identifier === "string"
|
||||||
|
? chat.identifier
|
||||||
|
: typeof chat.chatIdentifier === "string"
|
||||||
|
? chat.chatIdentifier
|
||||||
|
: typeof chat.chat_identifier === "string"
|
||||||
|
? chat.chat_identifier
|
||||||
|
: "";
|
||||||
|
if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat);
|
||||||
|
}
|
||||||
|
if (normalizedHandle) {
|
||||||
|
const participants = extractParticipantAddresses(chat).map((entry) =>
|
||||||
|
normalizeBlueBubblesHandle(entry),
|
||||||
|
);
|
||||||
|
if (participants.includes(normalizedHandle)) {
|
||||||
|
return extractChatGuid(chat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessageBlueBubbles(
|
||||||
|
to: string,
|
||||||
|
text: string,
|
||||||
|
opts: BlueBubblesSendOpts = {},
|
||||||
|
): Promise<BlueBubblesSendResult> {
|
||||||
|
const trimmedText = text ?? "";
|
||||||
|
if (!trimmedText.trim()) {
|
||||||
|
throw new Error("BlueBubbles send requires text");
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = resolveBlueBubblesAccount({
|
||||||
|
cfg: opts.cfg ?? {},
|
||||||
|
accountId: opts.accountId,
|
||||||
|
});
|
||||||
|
const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||||
|
const password = opts.password?.trim() || account.config.password?.trim();
|
||||||
|
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
|
||||||
|
if (!password) throw new Error("BlueBubbles password is required");
|
||||||
|
|
||||||
|
const target = resolveSendTarget(to);
|
||||||
|
const chatGuid = await resolveChatGuidForTarget({
|
||||||
|
baseUrl,
|
||||||
|
password,
|
||||||
|
timeoutMs: opts.timeoutMs,
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
if (!chatGuid) {
|
||||||
|
throw new Error(
|
||||||
|
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
chatGuid,
|
||||||
|
tempGuid: crypto.randomUUID(),
|
||||||
|
message: trimmedText,
|
||||||
|
method: "apple-script",
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
baseUrl,
|
||||||
|
path: "/api/v1/message/text",
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
const res = await blueBubblesFetchWithTimeout(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
opts.timeoutMs,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text();
|
||||||
|
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
|
}
|
||||||
|
const body = await res.text();
|
||||||
|
if (!body) return { messageId: "ok" };
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body) as unknown;
|
||||||
|
return { messageId: extractMessageId(parsed) };
|
||||||
|
} catch {
|
||||||
|
return { messageId: "ok" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
export type BlueBubblesService = "imessage" | "sms" | "auto";
|
||||||
|
|
||||||
|
export type BlueBubblesTarget =
|
||||||
|
| { kind: "chat_id"; chatId: number }
|
||||||
|
| { kind: "chat_guid"; chatGuid: string }
|
||||||
|
| { kind: "chat_identifier"; chatIdentifier: string }
|
||||||
|
| { kind: "handle"; to: string; service: BlueBubblesService };
|
||||||
|
|
||||||
|
export type BlueBubblesAllowTarget =
|
||||||
|
| { kind: "chat_id"; chatId: number }
|
||||||
|
| { kind: "chat_guid"; chatGuid: string }
|
||||||
|
| { kind: "chat_identifier"; chatIdentifier: string }
|
||||||
|
| { kind: "handle"; handle: string };
|
||||||
|
|
||||||
|
const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"];
|
||||||
|
const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"];
|
||||||
|
const CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"];
|
||||||
|
const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> = [
|
||||||
|
{ prefix: "imessage:", service: "imessage" },
|
||||||
|
{ prefix: "sms:", service: "sms" },
|
||||||
|
{ prefix: "auto:", service: "auto" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function stripPrefix(value: string, prefix: string): string {
|
||||||
|
return value.slice(prefix.length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBlueBubblesHandle(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
const lowered = trimmed.toLowerCase();
|
||||||
|
if (lowered.startsWith("imessage:")) return normalizeBlueBubblesHandle(trimmed.slice(9));
|
||||||
|
if (lowered.startsWith("sms:")) return normalizeBlueBubblesHandle(trimmed.slice(4));
|
||||||
|
if (lowered.startsWith("auto:")) return normalizeBlueBubblesHandle(trimmed.slice(5));
|
||||||
|
if (trimmed.includes("@")) return trimmed.toLowerCase();
|
||||||
|
return trimmed.replace(/\s+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) throw new Error("BlueBubbles target is required");
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
|
||||||
|
for (const { prefix, service } of SERVICE_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const remainder = stripPrefix(trimmed, prefix);
|
||||||
|
if (!remainder) throw new Error(`${prefix} target is required`);
|
||||||
|
const remainderLower = remainder.toLowerCase();
|
||||||
|
const isChatTarget =
|
||||||
|
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
||||||
|
CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
||||||
|
CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
||||||
|
remainderLower.startsWith("group:");
|
||||||
|
if (isChatTarget) {
|
||||||
|
return parseBlueBubblesTarget(remainder);
|
||||||
|
}
|
||||||
|
return { kind: "handle", to: remainder, service };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prefix of CHAT_ID_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const value = stripPrefix(trimmed, prefix);
|
||||||
|
const chatId = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(chatId)) {
|
||||||
|
throw new Error(`Invalid chat_id: ${value}`);
|
||||||
|
}
|
||||||
|
return { kind: "chat_id", chatId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prefix of CHAT_GUID_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const value = stripPrefix(trimmed, prefix);
|
||||||
|
if (!value) throw new Error("chat_guid is required");
|
||||||
|
return { kind: "chat_guid", chatGuid: value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const value = stripPrefix(trimmed, prefix);
|
||||||
|
if (!value) throw new Error("chat_identifier is required");
|
||||||
|
return { kind: "chat_identifier", chatIdentifier: value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.startsWith("group:")) {
|
||||||
|
const value = stripPrefix(trimmed, "group:");
|
||||||
|
const chatId = Number.parseInt(value, 10);
|
||||||
|
if (Number.isFinite(chatId)) {
|
||||||
|
return { kind: "chat_id", chatId };
|
||||||
|
}
|
||||||
|
if (!value) throw new Error("group target is required");
|
||||||
|
return { kind: "chat_guid", chatGuid: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "handle", to: trimmed, service: "auto" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return { kind: "handle", handle: "" };
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
|
||||||
|
for (const { prefix } of SERVICE_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const remainder = stripPrefix(trimmed, prefix);
|
||||||
|
if (!remainder) return { kind: "handle", handle: "" };
|
||||||
|
return parseBlueBubblesAllowTarget(remainder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prefix of CHAT_ID_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const value = stripPrefix(trimmed, prefix);
|
||||||
|
const chatId = Number.parseInt(value, 10);
|
||||||
|
if (Number.isFinite(chatId)) return { kind: "chat_id", chatId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prefix of CHAT_GUID_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const value = stripPrefix(trimmed, prefix);
|
||||||
|
if (value) return { kind: "chat_guid", chatGuid: value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
const value = stripPrefix(trimmed, prefix);
|
||||||
|
if (value) return { kind: "chat_identifier", chatIdentifier: value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.startsWith("group:")) {
|
||||||
|
const value = stripPrefix(trimmed, "group:");
|
||||||
|
const chatId = Number.parseInt(value, 10);
|
||||||
|
if (Number.isFinite(chatId)) return { kind: "chat_id", chatId };
|
||||||
|
if (value) return { kind: "chat_guid", chatGuid: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAllowedBlueBubblesSender(params: {
|
||||||
|
allowFrom: Array<string | number>;
|
||||||
|
sender: string;
|
||||||
|
chatId?: number | null;
|
||||||
|
chatGuid?: string | null;
|
||||||
|
chatIdentifier?: string | null;
|
||||||
|
}): boolean {
|
||||||
|
const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
|
||||||
|
if (allowFrom.length === 0) return true;
|
||||||
|
if (allowFrom.includes("*")) return true;
|
||||||
|
|
||||||
|
const senderNormalized = normalizeBlueBubblesHandle(params.sender);
|
||||||
|
const chatId = params.chatId ?? undefined;
|
||||||
|
const chatGuid = params.chatGuid?.trim();
|
||||||
|
const chatIdentifier = params.chatIdentifier?.trim();
|
||||||
|
|
||||||
|
for (const entry of allowFrom) {
|
||||||
|
if (!entry) continue;
|
||||||
|
const parsed = parseBlueBubblesAllowTarget(entry);
|
||||||
|
if (parsed.kind === "chat_id" && chatId !== undefined) {
|
||||||
|
if (parsed.chatId === chatId) return true;
|
||||||
|
} else if (parsed.kind === "chat_guid" && chatGuid) {
|
||||||
|
if (parsed.chatGuid === chatGuid) return true;
|
||||||
|
} else if (parsed.kind === "chat_identifier" && chatIdentifier) {
|
||||||
|
if (parsed.chatIdentifier === chatIdentifier) return true;
|
||||||
|
} else if (parsed.kind === "handle" && senderNormalized) {
|
||||||
|
if (parsed.handle === senderNormalized) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBlueBubblesChatTarget(params: {
|
||||||
|
chatId?: number | null;
|
||||||
|
chatGuid?: string | null;
|
||||||
|
chatIdentifier?: string | null;
|
||||||
|
}): string {
|
||||||
|
if (params.chatId && Number.isFinite(params.chatId)) {
|
||||||
|
return `chat_id:${params.chatId}`;
|
||||||
|
}
|
||||||
|
const guid = params.chatGuid?.trim();
|
||||||
|
if (guid) return `chat_guid:${guid}`;
|
||||||
|
const identifier = params.chatIdentifier?.trim();
|
||||||
|
if (identifier) return `chat_identifier:${identifier}`;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
||||||
|
export type GroupPolicy = "open" | "disabled" | "allowlist";
|
||||||
|
|
||||||
|
export type BlueBubblesAccountConfig = {
|
||||||
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
|
name?: string;
|
||||||
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
|
capabilities?: string[];
|
||||||
|
/** Allow channel-initiated config writes (default: true). */
|
||||||
|
configWrites?: boolean;
|
||||||
|
/** If false, do not start this BlueBubbles account. Default: true. */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Base URL for the BlueBubbles API. */
|
||||||
|
serverUrl?: string;
|
||||||
|
/** Password for BlueBubbles API authentication. */
|
||||||
|
password?: string;
|
||||||
|
/** Webhook path for the gateway HTTP server. */
|
||||||
|
webhookPath?: string;
|
||||||
|
/** Direct message access policy (default: pairing). */
|
||||||
|
dmPolicy?: DmPolicy;
|
||||||
|
allowFrom?: Array<string | number>;
|
||||||
|
/** Optional allowlist for group senders. */
|
||||||
|
groupAllowFrom?: Array<string | number>;
|
||||||
|
/** Group message handling policy. */
|
||||||
|
groupPolicy?: GroupPolicy;
|
||||||
|
/** Max group messages to keep as history context (0 disables). */
|
||||||
|
historyLimit?: number;
|
||||||
|
/** Max DM turns to keep as history context. */
|
||||||
|
dmHistoryLimit?: number;
|
||||||
|
/** Per-DM config overrides keyed by user ID. */
|
||||||
|
dms?: Record<string, unknown>;
|
||||||
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
|
textChunkLimit?: number;
|
||||||
|
blockStreaming?: boolean;
|
||||||
|
/** Merge streamed block replies before sending. */
|
||||||
|
blockStreamingCoalesce?: Record<string, unknown>;
|
||||||
|
/** Max outbound media size in MB. */
|
||||||
|
mediaMaxMb?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlueBubblesActionConfig = {
|
||||||
|
reactions?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlueBubblesConfig = {
|
||||||
|
/** Optional per-account BlueBubbles configuration (multi-account). */
|
||||||
|
accounts?: Record<string, BlueBubblesAccountConfig>;
|
||||||
|
/** Per-action tool gating (default: true for all). */
|
||||||
|
actions?: BlueBubblesActionConfig;
|
||||||
|
} & BlueBubblesAccountConfig;
|
||||||
|
|
||||||
|
export type BlueBubblesSendTarget =
|
||||||
|
| { kind: "chat_id"; chatId: number }
|
||||||
|
| { kind: "chat_guid"; chatGuid: string }
|
||||||
|
| { kind: "chat_identifier"; chatIdentifier: string }
|
||||||
|
| { kind: "handle"; address: string; service?: "imessage" | "sms" | "auto" };
|
||||||
|
|
||||||
|
export type BlueBubblesAttachment = {
|
||||||
|
guid?: string;
|
||||||
|
uti?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
transferName?: string;
|
||||||
|
totalBytes?: number;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
originalROWID?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
export function normalizeBlueBubblesServerUrl(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error("BlueBubbles serverUrl is required");
|
||||||
|
}
|
||||||
|
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
||||||
|
return withScheme.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBlueBubblesApiUrl(params: {
|
||||||
|
baseUrl: string;
|
||||||
|
path: string;
|
||||||
|
password?: string;
|
||||||
|
}): string {
|
||||||
|
const normalized = normalizeBlueBubblesServerUrl(params.baseUrl);
|
||||||
|
const url = new URL(params.path, `${normalized}/`);
|
||||||
|
if (params.password) {
|
||||||
|
url.searchParams.set("password", params.password);
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function blueBubblesFetchWithTimeout(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||||
|
) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...init, signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clawdbot": "workspace:*",
|
||||||
"markdown-it": "14.1.0",
|
"markdown-it": "14.1.0",
|
||||||
"matrix-js-sdk": "40.0.0"
|
"matrix-js-sdk": "40.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -552,6 +552,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
to: `room:${roomId}`,
|
to: `room:${roomId}`,
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
|
ctx: ctxPayload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/memory-core",
|
||||||
|
"version": "2026.1.17-1",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot core memory search plugin",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clawdbot": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clawdbot": "workspace:*",
|
||||||
"@microsoft/agents-hosting": "^1.1.1",
|
"@microsoft/agents-hosting": "^1.1.1",
|
||||||
"@microsoft/agents-hosting-express": "^1.1.1",
|
"@microsoft/agents-hosting-express": "^1.1.1",
|
||||||
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
|
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
|||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { verifyPlivoWebhook } from "./webhook-security.js";
|
import { verifyPlivoWebhook, verifyTwilioWebhook } from "./webhook-security.js";
|
||||||
|
|
||||||
function canonicalizeBase64(input: string): string {
|
function canonicalizeBase64(input: string): string {
|
||||||
return Buffer.from(input, "base64").toString("base64");
|
return Buffer.from(input, "base64").toString("base64");
|
||||||
@@ -71,6 +71,26 @@ function plivoV3Signature(params: {
|
|||||||
return canonicalizeBase64(digest);
|
return canonicalizeBase64(digest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function twilioSignature(params: {
|
||||||
|
authToken: string;
|
||||||
|
url: string;
|
||||||
|
postBody: string;
|
||||||
|
}): string {
|
||||||
|
let dataToSign = params.url;
|
||||||
|
const sortedParams = Array.from(
|
||||||
|
new URLSearchParams(params.postBody).entries(),
|
||||||
|
).sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
|
||||||
|
for (const [key, value] of sortedParams) {
|
||||||
|
dataToSign += key + value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto
|
||||||
|
.createHmac("sha1", params.authToken)
|
||||||
|
.update(dataToSign)
|
||||||
|
.digest("base64");
|
||||||
|
}
|
||||||
|
|
||||||
describe("verifyPlivoWebhook", () => {
|
describe("verifyPlivoWebhook", () => {
|
||||||
it("accepts valid V2 signature", () => {
|
it("accepts valid V2 signature", () => {
|
||||||
const authToken = "test-auth-token";
|
const authToken = "test-auth-token";
|
||||||
@@ -154,3 +174,35 @@ describe("verifyPlivoWebhook", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("verifyTwilioWebhook", () => {
|
||||||
|
it("uses request query when publicUrl omits it", () => {
|
||||||
|
const authToken = "test-auth-token";
|
||||||
|
const publicUrl = "https://example.com/voice/webhook";
|
||||||
|
const urlWithQuery = `${publicUrl}?callId=abc`;
|
||||||
|
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
||||||
|
|
||||||
|
const signature = twilioSignature({
|
||||||
|
authToken,
|
||||||
|
url: urlWithQuery,
|
||||||
|
postBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = verifyTwilioWebhook(
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
host: "example.com",
|
||||||
|
"x-forwarded-proto": "https",
|
||||||
|
"x-twilio-signature": signature,
|
||||||
|
},
|
||||||
|
rawBody: postBody,
|
||||||
|
url: "http://local/voice/webhook?callId=abc",
|
||||||
|
method: "POST",
|
||||||
|
query: { callId: "abc" },
|
||||||
|
},
|
||||||
|
authToken,
|
||||||
|
{ publicUrl },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -98,6 +98,25 @@ export function reconstructWebhookUrl(ctx: WebhookContext): string {
|
|||||||
return `${proto}://${host}${path}`;
|
return `${proto}://${host}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTwilioVerificationUrl(
|
||||||
|
ctx: WebhookContext,
|
||||||
|
publicUrl?: string,
|
||||||
|
): string {
|
||||||
|
if (!publicUrl) {
|
||||||
|
return reconstructWebhookUrl(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = new URL(publicUrl);
|
||||||
|
const requestUrl = new URL(ctx.url);
|
||||||
|
base.pathname = requestUrl.pathname;
|
||||||
|
base.search = requestUrl.search;
|
||||||
|
return base.toString();
|
||||||
|
} catch {
|
||||||
|
return publicUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a header value, handling both string and string[] types.
|
* Get a header value, handling both string and string[] types.
|
||||||
*/
|
*/
|
||||||
@@ -154,7 +173,7 @@ export function verifyTwilioWebhook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reconstruct the URL Twilio used
|
// Reconstruct the URL Twilio used
|
||||||
const verificationUrl = options?.publicUrl || reconstructWebhookUrl(ctx);
|
const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl);
|
||||||
|
|
||||||
// Parse the body as URL-encoded params
|
// Parse the body as URL-encoded params
|
||||||
const params = new URLSearchParams(ctx.rawBody);
|
const params = new URLSearchParams(ctx.rawBody);
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
|||||||
|
|
||||||
import { zaloDock, zaloPlugin } from "./src/channel.js";
|
import { zaloDock, zaloPlugin } from "./src/channel.js";
|
||||||
import { handleZaloWebhookRequest } from "./src/monitor.js";
|
import { handleZaloWebhookRequest } from "./src/monitor.js";
|
||||||
|
import { setZaloRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
const plugin = {
|
const plugin = {
|
||||||
id: "zalo",
|
id: "zalo",
|
||||||
name: "Zalo",
|
name: "Zalo",
|
||||||
description: "Zalo channel plugin (Bot API)",
|
description: "Zalo channel plugin (Bot API)",
|
||||||
register(api: ClawdbotPluginApi) {
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setZaloRuntime(api.runtime);
|
||||||
api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
|
api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
|
||||||
api.registerHttpHandler(handleZaloWebhookRequest);
|
api.registerHttpHandler(handleZaloWebhookRequest);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clawdbot": "workspace:*",
|
||||||
"undici": "7.18.2"
|
"undici": "7.18.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,22 @@
|
|||||||
import type {
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
CoreConfig,
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||||
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[] {
|
import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
|
||||||
|
import { resolveZaloToken } from "./token.js";
|
||||||
|
|
||||||
|
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||||
const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
|
const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
|
||||||
if (!accounts || typeof accounts !== "object") return [];
|
if (!accounts || typeof accounts !== "object") return [];
|
||||||
return Object.keys(accounts).filter(Boolean);
|
return Object.keys(accounts).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listZaloAccountIds(cfg: CoreConfig): string[] {
|
export function listZaloAccountIds(cfg: ClawdbotConfig): string[] {
|
||||||
const ids = listConfiguredAccountIds(cfg);
|
const ids = listConfiguredAccountIds(cfg);
|
||||||
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
||||||
return ids.sort((a, b) => a.localeCompare(b));
|
return ids.sort((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultZaloAccountId(cfg: CoreConfig): string {
|
export function resolveDefaultZaloAccountId(cfg: ClawdbotConfig): string {
|
||||||
const zaloConfig = cfg.channels?.zalo as ZaloConfig | undefined;
|
const zaloConfig = cfg.channels?.zalo as ZaloConfig | undefined;
|
||||||
if (zaloConfig?.defaultAccount?.trim()) return zaloConfig.defaultAccount.trim();
|
if (zaloConfig?.defaultAccount?.trim()) return zaloConfig.defaultAccount.trim();
|
||||||
const ids = listZaloAccountIds(cfg);
|
const ids = listZaloAccountIds(cfg);
|
||||||
@@ -28,7 +25,7 @@ export function resolveDefaultZaloAccountId(cfg: CoreConfig): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveAccountConfig(
|
function resolveAccountConfig(
|
||||||
cfg: CoreConfig,
|
cfg: ClawdbotConfig,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
): ZaloAccountConfig | undefined {
|
): ZaloAccountConfig | undefined {
|
||||||
const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
|
const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
|
||||||
@@ -36,7 +33,7 @@ function resolveAccountConfig(
|
|||||||
return accounts[accountId] as ZaloAccountConfig | undefined;
|
return accounts[accountId] as ZaloAccountConfig | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeZaloAccountConfig(cfg: CoreConfig, accountId: string): ZaloAccountConfig {
|
function mergeZaloAccountConfig(cfg: ClawdbotConfig, accountId: string): ZaloAccountConfig {
|
||||||
const raw = (cfg.channels?.zalo ?? {}) as ZaloConfig;
|
const raw = (cfg.channels?.zalo ?? {}) as ZaloConfig;
|
||||||
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
||||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||||
@@ -44,7 +41,7 @@ function mergeZaloAccountConfig(cfg: CoreConfig, accountId: string): ZaloAccount
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveZaloAccount(params: {
|
export function resolveZaloAccount(params: {
|
||||||
cfg: CoreConfig;
|
cfg: ClawdbotConfig;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
}): ResolvedZaloAccount {
|
}): ResolvedZaloAccount {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
@@ -67,7 +64,7 @@ export function resolveZaloAccount(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listEnabledZaloAccounts(cfg: CoreConfig): ResolvedZaloAccount[] {
|
export function listEnabledZaloAccounts(cfg: ClawdbotConfig): ResolvedZaloAccount[] {
|
||||||
return listZaloAccountIds(cfg)
|
return listZaloAccountIds(cfg)
|
||||||
.map((accountId) => resolveZaloAccount({ cfg, accountId }))
|
.map((accountId) => resolveZaloAccount({ cfg, accountId }))
|
||||||
.filter((account) => account.enabled);
|
.filter((account) => account.enabled);
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import type {
|
import type {
|
||||||
ChannelMessageActionAdapter,
|
ChannelMessageActionAdapter,
|
||||||
ChannelMessageActionName,
|
ChannelMessageActionName,
|
||||||
|
ClawdbotConfig,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
import { jsonResult, readStringParam } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import type { CoreConfig } from "./types.js";
|
|
||||||
import { listEnabledZaloAccounts } from "./accounts.js";
|
import { listEnabledZaloAccounts } from "./accounts.js";
|
||||||
import { sendMessageZalo } from "./send.js";
|
import { sendMessageZalo } from "./send.js";
|
||||||
import { jsonResult, readStringParam } from "./tool-helpers.js";
|
|
||||||
|
|
||||||
const providerId = "zalo";
|
const providerId = "zalo";
|
||||||
|
|
||||||
function listEnabledAccounts(cfg: CoreConfig) {
|
function listEnabledAccounts(cfg: ClawdbotConfig) {
|
||||||
return listEnabledZaloAccounts(cfg).filter(
|
return listEnabledZaloAccounts(cfg).filter(
|
||||||
(account) => account.enabled && account.tokenSource !== "none",
|
(account) => account.enabled && account.tokenSource !== "none",
|
||||||
);
|
);
|
||||||
@@ -18,7 +18,7 @@ function listEnabledAccounts(cfg: CoreConfig) {
|
|||||||
|
|
||||||
export const zaloMessageActions: ChannelMessageActionAdapter = {
|
export const zaloMessageActions: ChannelMessageActionAdapter = {
|
||||||
listActions: ({ cfg }) => {
|
listActions: ({ cfg }) => {
|
||||||
const accounts = listEnabledAccounts(cfg as CoreConfig);
|
const accounts = listEnabledAccounts(cfg as ClawdbotConfig);
|
||||||
if (accounts.length === 0) return [];
|
if (accounts.length === 0) return [];
|
||||||
const actions = new Set<ChannelMessageActionName>(["send"]);
|
const actions = new Set<ChannelMessageActionName>(["send"]);
|
||||||
return Array.from(actions);
|
return Array.from(actions);
|
||||||
@@ -44,7 +44,7 @@ export const zaloMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const result = await sendMessageZalo(to ?? "", content ?? "", {
|
const result = await sendMessageZalo(to ?? "", content ?? "", {
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
mediaUrl: mediaUrl ?? undefined,
|
mediaUrl: mediaUrl ?? undefined,
|
||||||
cfg: cfg as CoreConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { CoreConfig } from "./types.js";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import { zaloPlugin } from "./channel.js";
|
import { zaloPlugin } from "./channel.js";
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ describe("zalo directory", () => {
|
|||||||
allowFrom: ["zalo:123", "zl:234", "345"],
|
allowFrom: ["zalo:123", "zl:234", "345"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as CoreConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
expect(zaloPlugin.directory).toBeTruthy();
|
expect(zaloPlugin.directory).toBeTruthy();
|
||||||
expect(zaloPlugin.directory?.listPeers).toBeTruthy();
|
expect(zaloPlugin.directory?.listPeers).toBeTruthy();
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
import type { ChannelAccountSnapshot, ChannelDock, ChannelPlugin } from "clawdbot/plugin-sdk";
|
import type {
|
||||||
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
|
ChannelAccountSnapshot,
|
||||||
|
ChannelDock,
|
||||||
|
ChannelPlugin,
|
||||||
|
ClawdbotConfig,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
import {
|
||||||
|
applyAccountNameToChannelSection,
|
||||||
|
buildChannelConfigSchema,
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
deleteAccountFromConfigSection,
|
||||||
|
formatPairingApproveHint,
|
||||||
|
migrateBaseNameToDefaultAccount,
|
||||||
|
normalizeAccountId,
|
||||||
|
PAIRING_APPROVED_MESSAGE,
|
||||||
|
setAccountEnabledInConfigSection,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount, type ResolvedZaloAccount } from "./accounts.js";
|
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount, type ResolvedZaloAccount } from "./accounts.js";
|
||||||
import { zaloMessageActions } from "./actions.js";
|
import { zaloMessageActions } from "./actions.js";
|
||||||
import { ZaloConfigSchema } from "./config-schema.js";
|
import { ZaloConfigSchema } from "./config-schema.js";
|
||||||
import {
|
|
||||||
deleteAccountFromConfigSection,
|
|
||||||
setAccountEnabledInConfigSection,
|
|
||||||
} from "./shared/channel-config.js";
|
|
||||||
import { zaloOnboardingAdapter } from "./onboarding.js";
|
import { zaloOnboardingAdapter } from "./onboarding.js";
|
||||||
import { formatPairingApproveHint, PAIRING_APPROVED_MESSAGE } from "./shared/pairing.js";
|
|
||||||
import { resolveZaloProxyFetch } from "./proxy.js";
|
import { resolveZaloProxyFetch } from "./proxy.js";
|
||||||
import { probeZalo } from "./probe.js";
|
import { probeZalo } from "./probe.js";
|
||||||
import { sendMessageZalo } from "./send.js";
|
import { sendMessageZalo } from "./send.js";
|
||||||
import {
|
|
||||||
applyAccountNameToChannelSection,
|
|
||||||
migrateBaseNameToDefaultAccount,
|
|
||||||
} from "./shared/channel-setup.js";
|
|
||||||
import { collectZaloStatusIssues } from "./status-issues.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 = {
|
const meta = {
|
||||||
id: "zalo",
|
id: "zalo",
|
||||||
@@ -33,7 +37,6 @@ const meta = {
|
|||||||
quickstartAllowFrom: true,
|
quickstartAllowFrom: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
function normalizeZaloMessagingTarget(raw: string): string | undefined {
|
function normalizeZaloMessagingTarget(raw: string): string | undefined {
|
||||||
const trimmed = raw?.trim();
|
const trimmed = raw?.trim();
|
||||||
if (!trimmed) return undefined;
|
if (!trimmed) return undefined;
|
||||||
@@ -50,7 +53,7 @@ export const zaloDock: ChannelDock = {
|
|||||||
outbound: { textChunkLimit: 2000 },
|
outbound: { textChunkLimit: 2000 },
|
||||||
config: {
|
config: {
|
||||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
(resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ?? []).map(
|
||||||
(entry) => String(entry),
|
(entry) => String(entry),
|
||||||
),
|
),
|
||||||
formatAllowFrom: ({ allowFrom }) =>
|
formatAllowFrom: ({ allowFrom }) =>
|
||||||
@@ -84,12 +87,12 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
reload: { configPrefixes: ["channels.zalo"] },
|
reload: { configPrefixes: ["channels.zalo"] },
|
||||||
configSchema: buildChannelConfigSchema(ZaloConfigSchema),
|
configSchema: buildChannelConfigSchema(ZaloConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
listAccountIds: (cfg) => listZaloAccountIds(cfg as CoreConfig),
|
listAccountIds: (cfg) => listZaloAccountIds(cfg as ClawdbotConfig),
|
||||||
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }),
|
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId }),
|
||||||
defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg as CoreConfig),
|
defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg as ClawdbotConfig),
|
||||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||||
setAccountEnabledInConfigSection({
|
setAccountEnabledInConfigSection({
|
||||||
cfg: cfg as CoreConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
sectionKey: "zalo",
|
sectionKey: "zalo",
|
||||||
accountId,
|
accountId,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -97,7 +100,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
}),
|
}),
|
||||||
deleteAccount: ({ cfg, accountId }) =>
|
deleteAccount: ({ cfg, accountId }) =>
|
||||||
deleteAccountFromConfigSection({
|
deleteAccountFromConfigSection({
|
||||||
cfg: cfg as CoreConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
sectionKey: "zalo",
|
sectionKey: "zalo",
|
||||||
accountId,
|
accountId,
|
||||||
clearBaseFields: ["botToken", "tokenFile", "name"],
|
clearBaseFields: ["botToken", "tokenFile", "name"],
|
||||||
@@ -111,7 +114,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
tokenSource: account.tokenSource,
|
tokenSource: account.tokenSource,
|
||||||
}),
|
}),
|
||||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
(resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ?? []).map(
|
||||||
(entry) => String(entry),
|
(entry) => String(entry),
|
||||||
),
|
),
|
||||||
formatAllowFrom: ({ allowFrom }) =>
|
formatAllowFrom: ({ allowFrom }) =>
|
||||||
@@ -125,7 +128,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
const useAccountPath = Boolean(
|
const useAccountPath = Boolean(
|
||||||
(cfg as CoreConfig).channels?.zalo?.accounts?.[resolvedAccountId],
|
(cfg as ClawdbotConfig).channels?.zalo?.accounts?.[resolvedAccountId],
|
||||||
);
|
);
|
||||||
const basePath = useAccountPath
|
const basePath = useAccountPath
|
||||||
? `channels.zalo.accounts.${resolvedAccountId}.`
|
? `channels.zalo.accounts.${resolvedAccountId}.`
|
||||||
@@ -161,7 +164,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
directory: {
|
directory: {
|
||||||
self: async () => null,
|
self: async () => null,
|
||||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||||
const account = resolveZaloAccount({ cfg: cfg as CoreConfig, accountId });
|
const account = resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId });
|
||||||
const q = query?.trim().toLowerCase() || "";
|
const q = query?.trim().toLowerCase() || "";
|
||||||
const peers = Array.from(
|
const peers = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
@@ -182,7 +185,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||||
applyAccountName: ({ cfg, accountId, name }) =>
|
applyAccountName: ({ cfg, accountId, name }) =>
|
||||||
applyAccountNameToChannelSection({
|
applyAccountNameToChannelSection({
|
||||||
cfg: cfg as CoreConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
channelKey: "zalo",
|
channelKey: "zalo",
|
||||||
accountId,
|
accountId,
|
||||||
name,
|
name,
|
||||||
@@ -198,7 +201,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
},
|
},
|
||||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||||
const namedConfig = applyAccountNameToChannelSection({
|
const namedConfig = applyAccountNameToChannelSection({
|
||||||
cfg: cfg as CoreConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
channelKey: "zalo",
|
channelKey: "zalo",
|
||||||
accountId,
|
accountId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
@@ -227,7 +230,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...next,
|
...next,
|
||||||
@@ -250,14 +253,14 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pairing: {
|
pairing: {
|
||||||
idLabel: "zaloUserId",
|
idLabel: "zaloUserId",
|
||||||
normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""),
|
normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""),
|
||||||
notifyApproval: async ({ cfg, id }) => {
|
notifyApproval: async ({ cfg, id }) => {
|
||||||
const account = resolveZaloAccount({ cfg: cfg as CoreConfig });
|
const account = resolveZaloAccount({ cfg: cfg as ClawdbotConfig });
|
||||||
if (!account.token) throw new Error("Zalo token not configured");
|
if (!account.token) throw new Error("Zalo token not configured");
|
||||||
await sendMessageZalo(id, PAIRING_APPROVED_MESSAGE, { token: account.token });
|
await sendMessageZalo(id, PAIRING_APPROVED_MESSAGE, { token: account.token });
|
||||||
},
|
},
|
||||||
@@ -289,7 +292,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
sendText: async ({ to, text, accountId, cfg }) => {
|
sendText: async ({ to, text, accountId, cfg }) => {
|
||||||
const result = await sendMessageZalo(to, text, {
|
const result = await sendMessageZalo(to, text, {
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
cfg: cfg as CoreConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
channel: "zalo",
|
channel: "zalo",
|
||||||
@@ -302,7 +305,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
const result = await sendMessageZalo(to, text, {
|
const result = await sendMessageZalo(to, text, {
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
cfg: cfg as CoreConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
channel: "zalo",
|
channel: "zalo",
|
||||||
@@ -375,7 +378,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
return monitorZaloProvider({
|
return monitorZaloProvider({
|
||||||
token,
|
token,
|
||||||
account,
|
account,
|
||||||
config: ctx.cfg as CoreConfig,
|
config: ctx.cfg as ClawdbotConfig,
|
||||||
runtime: ctx.runtime,
|
runtime: ctx.runtime,
|
||||||
abortSignal: ctx.abortSignal,
|
abortSignal: ctx.abortSignal,
|
||||||
useWebhook: Boolean(account.config.webhookUrl),
|
useWebhook: Boolean(account.config.webhookUrl),
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
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 };
|
|
||||||
pairingAdapter?: {
|
|
||||||
idLabel: string;
|
|
||||||
normalizeAllowEntry?: (entry: string) => string;
|
|
||||||
notifyApproval?: (params: { cfg: unknown; id: string; runtime?: unknown }) => Promise<void>;
|
|
||||||
};
|
|
||||||
}) => 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;
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
||||||
import {
|
import {
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
|
formatAgentEnvelope,
|
||||||
isControlCommandMessage,
|
isControlCommandMessage,
|
||||||
recordSessionMetaFromInbound,
|
recordSessionMetaFromInbound,
|
||||||
resolveCommandAuthorizedFromAuthorizers,
|
resolveCommandAuthorizedFromAuthorizers,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
shouldComputeCommandAuthorized,
|
shouldComputeCommandAuthorized,
|
||||||
|
type ClawdbotConfig,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
ZaloApiError,
|
ZaloApiError,
|
||||||
deleteWebhook,
|
deleteWebhook,
|
||||||
@@ -20,10 +23,8 @@ import {
|
|||||||
type ZaloMessage,
|
type ZaloMessage,
|
||||||
type ZaloUpdate,
|
type ZaloUpdate,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { zaloPlugin } from "./channel.js";
|
|
||||||
import { loadCoreChannelDeps } from "./core-bridge.js";
|
|
||||||
import { resolveZaloProxyFetch } from "./proxy.js";
|
import { resolveZaloProxyFetch } from "./proxy.js";
|
||||||
import type { CoreConfig } from "./types.js";
|
import { getZaloRuntime } from "./runtime.js";
|
||||||
|
|
||||||
export type ZaloRuntimeEnv = {
|
export type ZaloRuntimeEnv = {
|
||||||
log?: (message: string) => void;
|
log?: (message: string) => void;
|
||||||
@@ -33,7 +34,7 @@ export type ZaloRuntimeEnv = {
|
|||||||
export type ZaloMonitorOptions = {
|
export type ZaloMonitorOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
account: ResolvedZaloAccount;
|
account: ResolvedZaloAccount;
|
||||||
config: CoreConfig;
|
config: ClawdbotConfig;
|
||||||
runtime: ZaloRuntimeEnv;
|
runtime: ZaloRuntimeEnv;
|
||||||
abortSignal: AbortSignal;
|
abortSignal: AbortSignal;
|
||||||
useWebhook?: boolean;
|
useWebhook?: boolean;
|
||||||
@@ -51,9 +52,11 @@ export type ZaloMonitorResult = {
|
|||||||
const ZALO_TEXT_LIMIT = 2000;
|
const ZALO_TEXT_LIMIT = 2000;
|
||||||
const DEFAULT_MEDIA_MAX_MB = 5;
|
const DEFAULT_MEDIA_MAX_MB = 5;
|
||||||
|
|
||||||
function logVerbose(deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>, message: string): void {
|
type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
|
||||||
if (deps.shouldLogVerbose()) {
|
|
||||||
console.log(`[zalo] ${message}`);
|
function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
|
||||||
|
if (core.logging.shouldLogVerbose()) {
|
||||||
|
runtime.log?.(`[zalo] ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,9 +103,9 @@ async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
|||||||
type WebhookTarget = {
|
type WebhookTarget = {
|
||||||
token: string;
|
token: string;
|
||||||
account: ResolvedZaloAccount;
|
account: ResolvedZaloAccount;
|
||||||
config: CoreConfig;
|
config: ClawdbotConfig;
|
||||||
runtime: ZaloRuntimeEnv;
|
runtime: ZaloRuntimeEnv;
|
||||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
|
core: ZaloCoreRuntime;
|
||||||
secret: string;
|
secret: string;
|
||||||
path: string;
|
path: string;
|
||||||
mediaMaxMb: number;
|
mediaMaxMb: number;
|
||||||
@@ -207,7 +210,7 @@ export async function handleZaloWebhookRequest(
|
|||||||
target.account,
|
target.account,
|
||||||
target.config,
|
target.config,
|
||||||
target.runtime,
|
target.runtime,
|
||||||
target.deps,
|
target.core,
|
||||||
target.mediaMaxMb,
|
target.mediaMaxMb,
|
||||||
target.statusSink,
|
target.statusSink,
|
||||||
target.fetcher,
|
target.fetcher,
|
||||||
@@ -223,9 +226,9 @@ export async function handleZaloWebhookRequest(
|
|||||||
function startPollingLoop(params: {
|
function startPollingLoop(params: {
|
||||||
token: string;
|
token: string;
|
||||||
account: ResolvedZaloAccount;
|
account: ResolvedZaloAccount;
|
||||||
config: CoreConfig;
|
config: ClawdbotConfig;
|
||||||
runtime: ZaloRuntimeEnv;
|
runtime: ZaloRuntimeEnv;
|
||||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
|
core: ZaloCoreRuntime;
|
||||||
abortSignal: AbortSignal;
|
abortSignal: AbortSignal;
|
||||||
isStopped: () => boolean;
|
isStopped: () => boolean;
|
||||||
mediaMaxMb: number;
|
mediaMaxMb: number;
|
||||||
@@ -237,7 +240,7 @@ function startPollingLoop(params: {
|
|||||||
account,
|
account,
|
||||||
config,
|
config,
|
||||||
runtime,
|
runtime,
|
||||||
deps,
|
core,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
isStopped,
|
isStopped,
|
||||||
mediaMaxMb,
|
mediaMaxMb,
|
||||||
@@ -259,7 +262,7 @@ function startPollingLoop(params: {
|
|||||||
account,
|
account,
|
||||||
config,
|
config,
|
||||||
runtime,
|
runtime,
|
||||||
deps,
|
core,
|
||||||
mediaMaxMb,
|
mediaMaxMb,
|
||||||
statusSink,
|
statusSink,
|
||||||
fetcher,
|
fetcher,
|
||||||
@@ -286,9 +289,9 @@ async function processUpdate(
|
|||||||
update: ZaloUpdate,
|
update: ZaloUpdate,
|
||||||
token: string,
|
token: string,
|
||||||
account: ResolvedZaloAccount,
|
account: ResolvedZaloAccount,
|
||||||
config: CoreConfig,
|
config: ClawdbotConfig,
|
||||||
runtime: ZaloRuntimeEnv,
|
runtime: ZaloRuntimeEnv,
|
||||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
|
core: ZaloCoreRuntime,
|
||||||
mediaMaxMb: number,
|
mediaMaxMb: number,
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
||||||
fetcher?: ZaloFetch,
|
fetcher?: ZaloFetch,
|
||||||
@@ -304,7 +307,7 @@ async function processUpdate(
|
|||||||
account,
|
account,
|
||||||
config,
|
config,
|
||||||
runtime,
|
runtime,
|
||||||
deps,
|
core,
|
||||||
statusSink,
|
statusSink,
|
||||||
fetcher,
|
fetcher,
|
||||||
);
|
);
|
||||||
@@ -316,7 +319,7 @@ async function processUpdate(
|
|||||||
account,
|
account,
|
||||||
config,
|
config,
|
||||||
runtime,
|
runtime,
|
||||||
deps,
|
core,
|
||||||
mediaMaxMb,
|
mediaMaxMb,
|
||||||
statusSink,
|
statusSink,
|
||||||
fetcher,
|
fetcher,
|
||||||
@@ -337,9 +340,9 @@ async function handleTextMessage(
|
|||||||
message: ZaloMessage,
|
message: ZaloMessage,
|
||||||
token: string,
|
token: string,
|
||||||
account: ResolvedZaloAccount,
|
account: ResolvedZaloAccount,
|
||||||
config: CoreConfig,
|
config: ClawdbotConfig,
|
||||||
runtime: ZaloRuntimeEnv,
|
runtime: ZaloRuntimeEnv,
|
||||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
|
core: ZaloCoreRuntime,
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
||||||
fetcher?: ZaloFetch,
|
fetcher?: ZaloFetch,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -352,7 +355,7 @@ async function handleTextMessage(
|
|||||||
account,
|
account,
|
||||||
config,
|
config,
|
||||||
runtime,
|
runtime,
|
||||||
deps,
|
core,
|
||||||
text,
|
text,
|
||||||
mediaPath: undefined,
|
mediaPath: undefined,
|
||||||
mediaType: undefined,
|
mediaType: undefined,
|
||||||
@@ -365,9 +368,9 @@ async function handleImageMessage(
|
|||||||
message: ZaloMessage,
|
message: ZaloMessage,
|
||||||
token: string,
|
token: string,
|
||||||
account: ResolvedZaloAccount,
|
account: ResolvedZaloAccount,
|
||||||
config: CoreConfig,
|
config: ClawdbotConfig,
|
||||||
runtime: ZaloRuntimeEnv,
|
runtime: ZaloRuntimeEnv,
|
||||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
|
core: ZaloCoreRuntime,
|
||||||
mediaMaxMb: number,
|
mediaMaxMb: number,
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
||||||
fetcher?: ZaloFetch,
|
fetcher?: ZaloFetch,
|
||||||
@@ -380,8 +383,8 @@ async function handleImageMessage(
|
|||||||
if (photo) {
|
if (photo) {
|
||||||
try {
|
try {
|
||||||
const maxBytes = mediaMaxMb * 1024 * 1024;
|
const maxBytes = mediaMaxMb * 1024 * 1024;
|
||||||
const fetched = await deps.fetchRemoteMedia({ url: photo });
|
const fetched = await core.channel.media.fetchRemoteMedia({ url: photo });
|
||||||
const saved = await deps.saveMediaBuffer(
|
const saved = await core.channel.media.saveMediaBuffer(
|
||||||
fetched.buffer,
|
fetched.buffer,
|
||||||
fetched.contentType,
|
fetched.contentType,
|
||||||
"inbound",
|
"inbound",
|
||||||
@@ -400,7 +403,7 @@ async function handleImageMessage(
|
|||||||
account,
|
account,
|
||||||
config,
|
config,
|
||||||
runtime,
|
runtime,
|
||||||
deps,
|
core,
|
||||||
text: caption,
|
text: caption,
|
||||||
mediaPath,
|
mediaPath,
|
||||||
mediaType,
|
mediaType,
|
||||||
@@ -413,9 +416,9 @@ async function processMessageWithPipeline(params: {
|
|||||||
message: ZaloMessage;
|
message: ZaloMessage;
|
||||||
token: string;
|
token: string;
|
||||||
account: ResolvedZaloAccount;
|
account: ResolvedZaloAccount;
|
||||||
config: CoreConfig;
|
config: ClawdbotConfig;
|
||||||
runtime: ZaloRuntimeEnv;
|
runtime: ZaloRuntimeEnv;
|
||||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
|
core: ZaloCoreRuntime;
|
||||||
text?: string;
|
text?: string;
|
||||||
mediaPath?: string;
|
mediaPath?: string;
|
||||||
mediaType?: string;
|
mediaType?: string;
|
||||||
@@ -428,7 +431,7 @@ async function processMessageWithPipeline(params: {
|
|||||||
account,
|
account,
|
||||||
config,
|
config,
|
||||||
runtime,
|
runtime,
|
||||||
deps,
|
core,
|
||||||
text,
|
text,
|
||||||
mediaPath,
|
mediaPath,
|
||||||
mediaType,
|
mediaType,
|
||||||
@@ -448,7 +451,7 @@ async function processMessageWithPipeline(params: {
|
|||||||
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
|
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
|
||||||
const storeAllowFrom =
|
const storeAllowFrom =
|
||||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
||||||
? await deps.readChannelAllowFromStore("zalo").catch(() => [])
|
? await core.channel.pairing.readAllowFromStore("zalo").catch(() => [])
|
||||||
: [];
|
: [];
|
||||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||||
@@ -462,7 +465,7 @@ async function processMessageWithPipeline(params: {
|
|||||||
|
|
||||||
if (!isGroup) {
|
if (!isGroup) {
|
||||||
if (dmPolicy === "disabled") {
|
if (dmPolicy === "disabled") {
|
||||||
logVerbose(deps, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
|
logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,21 +474,20 @@ async function processMessageWithPipeline(params: {
|
|||||||
|
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
if (dmPolicy === "pairing") {
|
if (dmPolicy === "pairing") {
|
||||||
const { code, created } = await deps.upsertChannelPairingRequest({
|
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||||
channel: "zalo",
|
channel: "zalo",
|
||||||
id: senderId,
|
id: senderId,
|
||||||
meta: { name: senderName ?? undefined },
|
meta: { name: senderName ?? undefined },
|
||||||
pairingAdapter: zaloPlugin.pairing,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (created) {
|
if (created) {
|
||||||
logVerbose(deps, `zalo pairing request sender=${senderId}`);
|
logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
|
||||||
try {
|
try {
|
||||||
await sendMessage(
|
await sendMessage(
|
||||||
token,
|
token,
|
||||||
{
|
{
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
text: deps.buildPairingReply({
|
text: core.channel.pairing.buildPairingReply({
|
||||||
channel: "zalo",
|
channel: "zalo",
|
||||||
idLine: `Your Zalo user id: ${senderId}`,
|
idLine: `Your Zalo user id: ${senderId}`,
|
||||||
code,
|
code,
|
||||||
@@ -495,18 +497,26 @@ async function processMessageWithPipeline(params: {
|
|||||||
);
|
);
|
||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(deps, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
|
logVerbose(
|
||||||
|
core,
|
||||||
|
runtime,
|
||||||
|
`zalo pairing reply failed for ${senderId}: ${String(err)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logVerbose(deps, `Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`);
|
logVerbose(
|
||||||
|
core,
|
||||||
|
runtime,
|
||||||
|
`Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = deps.resolveAgentRoute({
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
cfg: config,
|
cfg: config,
|
||||||
channel: "zalo",
|
channel: "zalo",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@@ -517,16 +527,14 @@ async function processMessageWithPipeline(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
|
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
|
||||||
logVerbose(deps, `zalo: drop control command from unauthorized sender ${senderId}`);
|
logVerbose(core, runtime, `zalo: drop control command from unauthorized sender ${senderId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromLabel = isGroup
|
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||||
? `group:${chatId}`
|
const body = formatAgentEnvelope({
|
||||||
: senderName || `user:${senderId}`;
|
channel: "Zalo",
|
||||||
const body = deps.formatAgentEnvelope({
|
from: fromLabel,
|
||||||
channel: "Zalo",
|
|
||||||
from: fromLabel,
|
|
||||||
timestamp: date ? date * 1000 : undefined,
|
timestamp: date ? date * 1000 : undefined,
|
||||||
body: rawBody,
|
body: rawBody,
|
||||||
});
|
});
|
||||||
@@ -565,7 +573,7 @@ async function processMessageWithPipeline(params: {
|
|||||||
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
|
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
await deps.dispatchReplyWithBufferedBlockDispatcher({
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg: config,
|
cfg: config,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
@@ -575,7 +583,7 @@ async function processMessageWithPipeline(params: {
|
|||||||
token,
|
token,
|
||||||
chatId,
|
chatId,
|
||||||
runtime,
|
runtime,
|
||||||
deps,
|
core,
|
||||||
statusSink,
|
statusSink,
|
||||||
fetcher,
|
fetcher,
|
||||||
});
|
});
|
||||||
@@ -592,11 +600,11 @@ async function deliverZaloReply(params: {
|
|||||||
token: string;
|
token: string;
|
||||||
chatId: string;
|
chatId: string;
|
||||||
runtime: ZaloRuntimeEnv;
|
runtime: ZaloRuntimeEnv;
|
||||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
|
core: ZaloCoreRuntime;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
fetcher?: ZaloFetch;
|
fetcher?: ZaloFetch;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { payload, token, chatId, runtime, deps, statusSink, fetcher } = params;
|
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
|
||||||
|
|
||||||
const mediaList = payload.mediaUrls?.length
|
const mediaList = payload.mediaUrls?.length
|
||||||
? payload.mediaUrls
|
? payload.mediaUrls
|
||||||
@@ -620,7 +628,7 @@ async function deliverZaloReply(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.text) {
|
if (payload.text) {
|
||||||
const chunks = deps.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT);
|
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
try {
|
try {
|
||||||
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
|
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
|
||||||
@@ -649,7 +657,7 @@ export async function monitorZaloProvider(
|
|||||||
fetcher: fetcherOverride,
|
fetcher: fetcherOverride,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const deps = await loadCoreChannelDeps();
|
const core = getZaloRuntime();
|
||||||
const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
||||||
const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
|
const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
|
||||||
|
|
||||||
@@ -686,7 +694,7 @@ export async function monitorZaloProvider(
|
|||||||
account,
|
account,
|
||||||
config,
|
config,
|
||||||
runtime,
|
runtime,
|
||||||
deps,
|
core,
|
||||||
path,
|
path,
|
||||||
secret: webhookSecret,
|
secret: webhookSecret,
|
||||||
statusSink: (patch) => statusSink?.(patch),
|
statusSink: (patch) => statusSink?.(patch),
|
||||||
@@ -715,7 +723,7 @@ export async function monitorZaloProvider(
|
|||||||
account,
|
account,
|
||||||
config,
|
config,
|
||||||
runtime,
|
runtime,
|
||||||
deps,
|
core,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
isStopped: () => stopped,
|
isStopped: () => stopped,
|
||||||
mediaMaxMb: effectiveMediaMaxMb,
|
mediaMaxMb: effectiveMediaMaxMb,
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import type { AddressInfo } from "node:net";
|
|||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { CoreConfig, ResolvedZaloAccount } from "./types.js";
|
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
import type { loadCoreChannelDeps } from "./core-bridge.js";
|
import type { ResolvedZaloAccount } from "./types.js";
|
||||||
import { handleZaloWebhookRequest, registerZaloWebhookTarget } from "./monitor.js";
|
import { handleZaloWebhookRequest, registerZaloWebhookTarget } from "./monitor.js";
|
||||||
|
|
||||||
async function withServer(
|
async function withServer(
|
||||||
@@ -26,7 +26,7 @@ async function withServer(
|
|||||||
|
|
||||||
describe("handleZaloWebhookRequest", () => {
|
describe("handleZaloWebhookRequest", () => {
|
||||||
it("returns 400 for non-object payloads", async () => {
|
it("returns 400 for non-object payloads", async () => {
|
||||||
const deps = {} as Awaited<ReturnType<typeof loadCoreChannelDeps>>;
|
const core = {} as PluginRuntime;
|
||||||
const account: ResolvedZaloAccount = {
|
const account: ResolvedZaloAccount = {
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -37,9 +37,9 @@ describe("handleZaloWebhookRequest", () => {
|
|||||||
const unregister = registerZaloWebhookTarget({
|
const unregister = registerZaloWebhookTarget({
|
||||||
token: "tok",
|
token: "tok",
|
||||||
account,
|
account,
|
||||||
config: {} as CoreConfig,
|
config: {} as ClawdbotConfig,
|
||||||
runtime: {},
|
runtime: {},
|
||||||
deps,
|
core,
|
||||||
secret: "secret",
|
secret: "secret",
|
||||||
path: "/hook",
|
path: "/hook",
|
||||||
mediaMaxMb: 5,
|
mediaMaxMb: 5,
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
import type {
|
import type {
|
||||||
ChannelOnboardingAdapter,
|
ChannelOnboardingAdapter,
|
||||||
ChannelOnboardingDmPolicy,
|
ChannelOnboardingDmPolicy,
|
||||||
|
ClawdbotConfig,
|
||||||
WizardPrompter,
|
WizardPrompter,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
import {
|
||||||
|
addWildcardAllowFrom,
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
normalizeAccountId,
|
||||||
|
promptAccountId,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import { addWildcardAllowFrom, promptAccountId } from "./shared/onboarding.js";
|
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./shared/account-ids.js";
|
|
||||||
import {
|
import {
|
||||||
listZaloAccountIds,
|
listZaloAccountIds,
|
||||||
resolveDefaultZaloAccountId,
|
resolveDefaultZaloAccountId,
|
||||||
resolveZaloAccount,
|
resolveZaloAccount,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
import type { CoreConfig } from "./types.js";
|
|
||||||
|
|
||||||
const channel = "zalo" as const;
|
const channel = "zalo" as const;
|
||||||
|
|
||||||
type UpdateMode = "polling" | "webhook";
|
type UpdateMode = "polling" | "webhook";
|
||||||
|
|
||||||
function setZaloDmPolicy(cfg: CoreConfig, dmPolicy: "pairing" | "allowlist" | "open" | "disabled") {
|
function setZaloDmPolicy(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
|
||||||
|
) {
|
||||||
const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.zalo?.allowFrom) : undefined;
|
const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.zalo?.allowFrom) : undefined;
|
||||||
return {
|
return {
|
||||||
...cfg,
|
...cfg,
|
||||||
@@ -29,17 +36,17 @@ function setZaloDmPolicy(cfg: CoreConfig, dmPolicy: "pairing" | "allowlist" | "o
|
|||||||
...(allowFrom ? { allowFrom } : {}),
|
...(allowFrom ? { allowFrom } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setZaloUpdateMode(
|
function setZaloUpdateMode(
|
||||||
cfg: CoreConfig,
|
cfg: ClawdbotConfig,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
mode: UpdateMode,
|
mode: UpdateMode,
|
||||||
webhookUrl?: string,
|
webhookUrl?: string,
|
||||||
webhookSecret?: string,
|
webhookSecret?: string,
|
||||||
webhookPath?: string,
|
webhookPath?: string,
|
||||||
): CoreConfig {
|
): ClawdbotConfig {
|
||||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||||
if (mode === "polling") {
|
if (mode === "polling") {
|
||||||
if (isDefault) {
|
if (isDefault) {
|
||||||
@@ -55,7 +62,7 @@ function setZaloUpdateMode(
|
|||||||
...cfg.channels,
|
...cfg.channels,
|
||||||
zalo: rest,
|
zalo: rest,
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
|
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
|
||||||
string,
|
string,
|
||||||
@@ -78,7 +85,7 @@ function setZaloUpdateMode(
|
|||||||
accounts,
|
accounts,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDefault) {
|
if (isDefault) {
|
||||||
@@ -93,7 +100,7 @@ function setZaloUpdateMode(
|
|||||||
webhookPath,
|
webhookPath,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
|
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
|
||||||
@@ -115,7 +122,7 @@ function setZaloUpdateMode(
|
|||||||
accounts,
|
accounts,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function noteZaloTokenHelp(prompter: WizardPrompter): Promise<void> {
|
async function noteZaloTokenHelp(prompter: WizardPrompter): Promise<void> {
|
||||||
@@ -132,10 +139,10 @@ async function noteZaloTokenHelp(prompter: WizardPrompter): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function promptZaloAllowFrom(params: {
|
async function promptZaloAllowFrom(params: {
|
||||||
cfg: CoreConfig;
|
cfg: ClawdbotConfig;
|
||||||
prompter: WizardPrompter;
|
prompter: WizardPrompter;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
}): Promise<CoreConfig> {
|
}): Promise<ClawdbotConfig> {
|
||||||
const { cfg, prompter, accountId } = params;
|
const { cfg, prompter, accountId } = params;
|
||||||
const resolved = resolveZaloAccount({ cfg, accountId });
|
const resolved = resolveZaloAccount({ cfg, accountId });
|
||||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||||
@@ -169,7 +176,7 @@ async function promptZaloAllowFrom(params: {
|
|||||||
allowFrom: unique,
|
allowFrom: unique,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -190,7 +197,7 @@ async function promptZaloAllowFrom(params: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||||
@@ -199,15 +206,15 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
|||||||
policyKey: "channels.zalo.dmPolicy",
|
policyKey: "channels.zalo.dmPolicy",
|
||||||
allowFromKey: "channels.zalo.allowFrom",
|
allowFromKey: "channels.zalo.allowFrom",
|
||||||
getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing",
|
getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing",
|
||||||
setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as CoreConfig, policy),
|
setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as ClawdbotConfig, policy),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||||
channel,
|
channel,
|
||||||
dmPolicy,
|
dmPolicy,
|
||||||
getStatus: async ({ cfg }) => {
|
getStatus: async ({ cfg }) => {
|
||||||
const configured = listZaloAccountIds(cfg as CoreConfig).some((accountId) =>
|
const configured = listZaloAccountIds(cfg as ClawdbotConfig).some((accountId) =>
|
||||||
Boolean(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).token),
|
Boolean(resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId }).token),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
channel,
|
channel,
|
||||||
@@ -219,13 +226,13 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
},
|
},
|
||||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds, forceAllowFrom }) => {
|
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds, forceAllowFrom }) => {
|
||||||
const zaloOverride = accountOverrides.zalo?.trim();
|
const zaloOverride = accountOverrides.zalo?.trim();
|
||||||
const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg as CoreConfig);
|
const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg as ClawdbotConfig);
|
||||||
let zaloAccountId = zaloOverride
|
let zaloAccountId = zaloOverride
|
||||||
? normalizeAccountId(zaloOverride)
|
? normalizeAccountId(zaloOverride)
|
||||||
: defaultZaloAccountId;
|
: defaultZaloAccountId;
|
||||||
if (shouldPromptAccountIds && !zaloOverride) {
|
if (shouldPromptAccountIds && !zaloOverride) {
|
||||||
zaloAccountId = await promptAccountId({
|
zaloAccountId = await promptAccountId({
|
||||||
cfg: cfg as CoreConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
prompter,
|
prompter,
|
||||||
label: "Zalo",
|
label: "Zalo",
|
||||||
currentId: zaloAccountId,
|
currentId: zaloAccountId,
|
||||||
@@ -234,7 +241,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let next = cfg as CoreConfig;
|
let next = cfg as ClawdbotConfig;
|
||||||
const resolvedAccount = resolveZaloAccount({ cfg: next, accountId: zaloAccountId });
|
const resolvedAccount = resolveZaloAccount({ cfg: next, accountId: zaloAccountId });
|
||||||
const accountConfigured = Boolean(resolvedAccount.token);
|
const accountConfigured = Boolean(resolvedAccount.token);
|
||||||
const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID;
|
const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID;
|
||||||
@@ -262,7 +269,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
} else {
|
} else {
|
||||||
token = String(
|
token = String(
|
||||||
await prompter.text({
|
await prompter.text({
|
||||||
@@ -305,7 +312,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
botToken: token,
|
botToken: token,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
} else {
|
} else {
|
||||||
next = {
|
next = {
|
||||||
...next,
|
...next,
|
||||||
@@ -324,7 +331,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
export function setZaloRuntime(next: PluginRuntime): void {
|
||||||
|
runtime = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getZaloRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("Zalo runtime not initialized");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CoreConfig } from "./types.js";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import type { ZaloFetch } from "./api.js";
|
import type { ZaloFetch } from "./api.js";
|
||||||
import { sendMessage, sendPhoto } from "./api.js";
|
import { sendMessage, sendPhoto } from "./api.js";
|
||||||
import { resolveZaloAccount } from "./accounts.js";
|
import { resolveZaloAccount } from "./accounts.js";
|
||||||
@@ -8,7 +9,7 @@ import { resolveZaloToken } from "./token.js";
|
|||||||
export type ZaloSendOptions = {
|
export type ZaloSendOptions = {
|
||||||
token?: string;
|
token?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
cfg?: CoreConfig;
|
cfg?: ClawdbotConfig;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import type { WizardPrompter } from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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>`;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import type { ZaloConfig } from "./types.js";
|
import type { ZaloConfig } from "./types.js";
|
||||||
import { DEFAULT_ACCOUNT_ID } from "./shared/account-ids.js";
|
|
||||||
|
|
||||||
export type ZaloTokenResolution = {
|
export type ZaloTokenResolution = {
|
||||||
token: string;
|
token: string;
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -40,10 +40,3 @@ export type ResolvedZaloAccount = {
|
|||||||
tokenSource: ZaloTokenSource;
|
tokenSource: ZaloTokenSource;
|
||||||
config: ZaloAccountConfig;
|
config: ZaloAccountConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CoreConfig = {
|
|
||||||
channels?: {
|
|
||||||
zalo?: ZaloConfig;
|
|
||||||
};
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
@@ -2,12 +2,14 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
|||||||
|
|
||||||
import { zalouserPlugin } from "./src/channel.js";
|
import { zalouserPlugin } from "./src/channel.js";
|
||||||
import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
|
import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
|
||||||
|
import { setZalouserRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
const plugin = {
|
const plugin = {
|
||||||
id: "zalouser",
|
id: "zalouser",
|
||||||
name: "Zalo Personal",
|
name: "Zalo Personal",
|
||||||
description: "Zalo personal account messaging via zca-cli",
|
description: "Zalo personal account messaging via zca-cli",
|
||||||
register(api: ClawdbotPluginApi) {
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setZalouserRuntime(api.runtime);
|
||||||
// Register channel plugin (for onboarding & gateway)
|
// Register channel plugin (for onboarding & gateway)
|
||||||
api.registerChannel(zalouserPlugin);
|
api.registerChannel(zalouserPlugin);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clawdbot": "workspace:*",
|
||||||
"@sinclair/typebox": "0.34.47"
|
"@sinclair/typebox": "0.34.47"
|
||||||
},
|
},
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
import { runZca, parseJsonOutput } from "./zca.js";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
import {
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||||
DEFAULT_ACCOUNT_ID,
|
|
||||||
type CoreConfig,
|
|
||||||
type ResolvedZalouserAccount,
|
|
||||||
type ZalouserAccountConfig,
|
|
||||||
type ZalouserConfig,
|
|
||||||
} from "./types.js";
|
|
||||||
|
|
||||||
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
|
import { runZca, parseJsonOutput } from "./zca.js";
|
||||||
|
import type { ResolvedZalouserAccount, ZalouserAccountConfig, ZalouserConfig } from "./types.js";
|
||||||
|
|
||||||
|
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||||
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
|
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
|
||||||
if (!accounts || typeof accounts !== "object") return [];
|
if (!accounts || typeof accounts !== "object") return [];
|
||||||
return Object.keys(accounts).filter(Boolean);
|
return Object.keys(accounts).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listZalouserAccountIds(cfg: CoreConfig): string[] {
|
export function listZalouserAccountIds(cfg: ClawdbotConfig): string[] {
|
||||||
const ids = listConfiguredAccountIds(cfg);
|
const ids = listConfiguredAccountIds(cfg);
|
||||||
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
||||||
return ids.sort((a, b) => a.localeCompare(b));
|
return ids.sort((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultZalouserAccountId(cfg: CoreConfig): string {
|
export function resolveDefaultZalouserAccountId(cfg: ClawdbotConfig): string {
|
||||||
const zalouserConfig = cfg.channels?.zalouser as ZalouserConfig | undefined;
|
const zalouserConfig = cfg.channels?.zalouser as ZalouserConfig | undefined;
|
||||||
if (zalouserConfig?.defaultAccount?.trim()) return zalouserConfig.defaultAccount.trim();
|
if (zalouserConfig?.defaultAccount?.trim()) return zalouserConfig.defaultAccount.trim();
|
||||||
const ids = listZalouserAccountIds(cfg);
|
const ids = listZalouserAccountIds(cfg);
|
||||||
@@ -27,14 +24,8 @@ export function resolveDefaultZalouserAccountId(cfg: CoreConfig): string {
|
|||||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeAccountId(accountId?: string | null): string {
|
|
||||||
const trimmed = accountId?.trim();
|
|
||||||
if (!trimmed) return DEFAULT_ACCOUNT_ID;
|
|
||||||
return trimmed.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAccountConfig(
|
function resolveAccountConfig(
|
||||||
cfg: CoreConfig,
|
cfg: ClawdbotConfig,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
): ZalouserAccountConfig | undefined {
|
): ZalouserAccountConfig | undefined {
|
||||||
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
|
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
|
||||||
@@ -42,7 +33,10 @@ function resolveAccountConfig(
|
|||||||
return accounts[accountId] as ZalouserAccountConfig | undefined;
|
return accounts[accountId] as ZalouserAccountConfig | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeZalouserAccountConfig(cfg: CoreConfig, accountId: string): ZalouserAccountConfig {
|
function mergeZalouserAccountConfig(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
accountId: string,
|
||||||
|
): ZalouserAccountConfig {
|
||||||
const raw = (cfg.channels?.zalouser ?? {}) as ZalouserConfig;
|
const raw = (cfg.channels?.zalouser ?? {}) as ZalouserConfig;
|
||||||
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
||||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||||
@@ -62,7 +56,7 @@ export async function checkZcaAuthenticated(profile: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveZalouserAccount(params: {
|
export async function resolveZalouserAccount(params: {
|
||||||
cfg: CoreConfig;
|
cfg: ClawdbotConfig;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
}): Promise<ResolvedZalouserAccount> {
|
}): Promise<ResolvedZalouserAccount> {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
@@ -84,7 +78,7 @@ export async function resolveZalouserAccount(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveZalouserAccountSync(params: {
|
export function resolveZalouserAccountSync(params: {
|
||||||
cfg: CoreConfig;
|
cfg: ClawdbotConfig;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
}): ResolvedZalouserAccount {
|
}): ResolvedZalouserAccount {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
@@ -104,7 +98,9 @@ export function resolveZalouserAccountSync(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listEnabledZalouserAccounts(cfg: CoreConfig): Promise<ResolvedZalouserAccount[]> {
|
export async function listEnabledZalouserAccounts(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
): Promise<ResolvedZalouserAccount[]> {
|
||||||
const ids = listZalouserAccountIds(cfg);
|
const ids = listZalouserAccountIds(cfg);
|
||||||
const accounts = await Promise.all(
|
const accounts = await Promise.all(
|
||||||
ids.map((accountId) => resolveZalouserAccount({ cfg, accountId }))
|
ids.map((accountId) => resolveZalouserAccount({ cfg, accountId }))
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import type { ChannelAccountSnapshot, ChannelDirectoryEntry, ChannelPlugin } from "clawdbot/plugin-sdk";
|
import type {
|
||||||
|
ChannelAccountSnapshot,
|
||||||
import { formatPairingApproveHint } from "clawdbot/plugin-sdk";
|
ChannelDirectoryEntry,
|
||||||
|
ChannelPlugin,
|
||||||
|
ClawdbotConfig,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
import {
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
deleteAccountFromConfigSection,
|
||||||
|
formatPairingApproveHint,
|
||||||
|
normalizeAccountId,
|
||||||
|
setAccountEnabledInConfigSection,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
listZalouserAccountIds,
|
listZalouserAccountIds,
|
||||||
resolveDefaultZalouserAccountId,
|
resolveDefaultZalouserAccountId,
|
||||||
@@ -12,14 +22,7 @@ import {
|
|||||||
import { zalouserOnboardingAdapter } from "./onboarding.js";
|
import { zalouserOnboardingAdapter } from "./onboarding.js";
|
||||||
import { sendMessageZalouser } from "./send.js";
|
import { sendMessageZalouser } from "./send.js";
|
||||||
import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
|
import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
|
||||||
import {
|
import type { ZcaFriend, ZcaGroup, ZcaUserInfo } from "./types.js";
|
||||||
DEFAULT_ACCOUNT_ID,
|
|
||||||
type CoreConfig,
|
|
||||||
type ZalouserConfig,
|
|
||||||
type ZcaFriend,
|
|
||||||
type ZcaGroup,
|
|
||||||
type ZcaUserInfo,
|
|
||||||
} from "./types.js";
|
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
id: "zalouser",
|
id: "zalouser",
|
||||||
@@ -34,7 +37,7 @@ const meta = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function resolveZalouserQrProfile(accountId?: string | null): string {
|
function resolveZalouserQrProfile(accountId?: string | null): string {
|
||||||
const normalized = String(accountId ?? "").trim();
|
const normalized = normalizeAccountId(accountId);
|
||||||
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
|
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
|
||||||
return process.env.ZCA_PROFILE?.trim() || "default";
|
return process.env.ZCA_PROFILE?.trim() || "default";
|
||||||
}
|
}
|
||||||
@@ -69,65 +72,6 @@ function mapGroup(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteAccountFromConfigSection(params: {
|
|
||||||
cfg: CoreConfig;
|
|
||||||
accountId: string;
|
|
||||||
}): CoreConfig {
|
|
||||||
const { cfg, accountId } = params;
|
|
||||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
||||||
const { zalouser: _removed, ...restChannels } = cfg.channels ?? {};
|
|
||||||
return { ...cfg, channels: restChannels };
|
|
||||||
}
|
|
||||||
const accounts = { ...(cfg.channels?.zalouser?.accounts ?? {}) };
|
|
||||||
delete accounts[accountId];
|
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
zalouser: {
|
|
||||||
...cfg.channels?.zalouser,
|
|
||||||
accounts,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAccountEnabledInConfigSection(params: {
|
|
||||||
cfg: CoreConfig;
|
|
||||||
accountId: string;
|
|
||||||
enabled: boolean;
|
|
||||||
}): CoreConfig {
|
|
||||||
const { cfg, accountId, enabled } = params;
|
|
||||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
zalouser: {
|
|
||||||
...cfg.channels?.zalouser,
|
|
||||||
enabled,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
zalouser: {
|
|
||||||
...cfg.channels?.zalouser,
|
|
||||||
accounts: {
|
|
||||||
...(cfg.channels?.zalouser?.accounts ?? {}),
|
|
||||||
[accountId]: {
|
|
||||||
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
|
|
||||||
enabled,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||||
id: "zalouser",
|
id: "zalouser",
|
||||||
meta,
|
meta,
|
||||||
@@ -143,20 +87,24 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
},
|
},
|
||||||
reload: { configPrefixes: ["channels.zalouser"] },
|
reload: { configPrefixes: ["channels.zalouser"] },
|
||||||
config: {
|
config: {
|
||||||
listAccountIds: (cfg) => listZalouserAccountIds(cfg as CoreConfig),
|
listAccountIds: (cfg) => listZalouserAccountIds(cfg as ClawdbotConfig),
|
||||||
resolveAccount: (cfg, accountId) =>
|
resolveAccount: (cfg, accountId) =>
|
||||||
resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }),
|
resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId }),
|
||||||
defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg as CoreConfig),
|
defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg as ClawdbotConfig),
|
||||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||||
setAccountEnabledInConfigSection({
|
setAccountEnabledInConfigSection({
|
||||||
cfg: cfg as CoreConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
sectionKey: "zalouser",
|
||||||
accountId,
|
accountId,
|
||||||
enabled,
|
enabled,
|
||||||
|
allowTopLevel: true,
|
||||||
}),
|
}),
|
||||||
deleteAccount: ({ cfg, accountId }) =>
|
deleteAccount: ({ cfg, accountId }) =>
|
||||||
deleteAccountFromConfigSection({
|
deleteAccountFromConfigSection({
|
||||||
cfg: cfg as CoreConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
sectionKey: "zalouser",
|
||||||
accountId,
|
accountId,
|
||||||
|
clearBaseFields: ["profile", "name", "dmPolicy", "allowFrom", "groupPolicy", "groups", "messagePrefix"],
|
||||||
}),
|
}),
|
||||||
isConfigured: async (account) => {
|
isConfigured: async (account) => {
|
||||||
// Check if zca auth status is OK for this profile
|
// Check if zca auth status is OK for this profile
|
||||||
@@ -173,7 +121,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
configured: undefined,
|
configured: undefined,
|
||||||
}),
|
}),
|
||||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
(resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
(resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ?? []).map(
|
||||||
(entry) => String(entry),
|
(entry) => String(entry),
|
||||||
),
|
),
|
||||||
formatAllowFrom: ({ allowFrom }) =>
|
formatAllowFrom: ({ allowFrom }) =>
|
||||||
@@ -187,7 +135,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
const useAccountPath = Boolean(
|
const useAccountPath = Boolean(
|
||||||
(cfg as CoreConfig).channels?.zalouser?.accounts?.[resolvedAccountId],
|
(cfg as ClawdbotConfig).channels?.zalouser?.accounts?.[resolvedAccountId],
|
||||||
);
|
);
|
||||||
const basePath = useAccountPath
|
const basePath = useAccountPath
|
||||||
? `channels.zalouser.accounts.${resolvedAccountId}.`
|
? `channels.zalouser.accounts.${resolvedAccountId}.`
|
||||||
@@ -227,7 +175,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
self: async ({ cfg, accountId, runtime }) => {
|
self: async ({ cfg, accountId, runtime }) => {
|
||||||
const ok = await checkZcaInstalled();
|
const ok = await checkZcaInstalled();
|
||||||
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
||||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
|
||||||
const result = await runZca(["me", "info", "-j"], { profile: account.profile, timeout: 10000 });
|
const result = await runZca(["me", "info", "-j"], { profile: account.profile, timeout: 10000 });
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
runtime.error(result.stderr || "Failed to fetch profile");
|
runtime.error(result.stderr || "Failed to fetch profile");
|
||||||
@@ -245,7 +193,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||||
const ok = await checkZcaInstalled();
|
const ok = await checkZcaInstalled();
|
||||||
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
||||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
|
||||||
const args = query?.trim()
|
const args = query?.trim()
|
||||||
? ["friend", "find", query.trim()]
|
? ["friend", "find", query.trim()]
|
||||||
: ["friend", "list", "-j"];
|
: ["friend", "list", "-j"];
|
||||||
@@ -269,7 +217,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||||
const ok = await checkZcaInstalled();
|
const ok = await checkZcaInstalled();
|
||||||
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
||||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
|
||||||
const result = await runZca(["group", "list", "-j"], { profile: account.profile, timeout: 15000 });
|
const result = await runZca(["group", "list", "-j"], { profile: account.profile, timeout: 15000 });
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new Error(result.stderr || "Failed to list groups");
|
throw new Error(result.stderr || "Failed to list groups");
|
||||||
@@ -293,7 +241,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
|
listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
|
||||||
const ok = await checkZcaInstalled();
|
const ok = await checkZcaInstalled();
|
||||||
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
||||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
|
||||||
const result = await runZca(["group", "members", groupId, "-j"], {
|
const result = await runZca(["group", "members", groupId, "-j"], {
|
||||||
profile: account.profile,
|
profile: account.profile,
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
@@ -335,7 +283,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const account = resolveZalouserAccountSync({
|
const account = resolveZalouserAccountSync({
|
||||||
cfg: cfg as CoreConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
||||||
});
|
});
|
||||||
const args =
|
const args =
|
||||||
@@ -391,7 +339,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
idLabel: "zalouserUserId",
|
idLabel: "zalouserUserId",
|
||||||
normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""),
|
normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""),
|
||||||
notifyApproval: async ({ cfg, id }) => {
|
notifyApproval: async ({ cfg, id }) => {
|
||||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig });
|
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig });
|
||||||
const authenticated = await checkZcaAuthenticated(account.profile);
|
const authenticated = await checkZcaAuthenticated(account.profile);
|
||||||
if (!authenticated) throw new Error("Zalouser not authenticated");
|
if (!authenticated) throw new Error("Zalouser not authenticated");
|
||||||
await sendMessageZalouser(id, "Your pairing request has been approved.", {
|
await sendMessageZalouser(id, "Your pairing request has been approved.", {
|
||||||
@@ -402,7 +350,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
auth: {
|
auth: {
|
||||||
login: async ({ cfg, accountId, runtime }) => {
|
login: async ({ cfg, accountId, runtime }) => {
|
||||||
const account = resolveZalouserAccountSync({
|
const account = resolveZalouserAccountSync({
|
||||||
cfg: cfg as CoreConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
||||||
});
|
});
|
||||||
const ok = await checkZcaInstalled();
|
const ok = await checkZcaInstalled();
|
||||||
@@ -445,7 +393,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
},
|
},
|
||||||
textChunkLimit: 2000,
|
textChunkLimit: 2000,
|
||||||
sendText: async ({ to, text, accountId, cfg }) => {
|
sendText: async ({ to, text, accountId, cfg }) => {
|
||||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
|
||||||
const result = await sendMessageZalouser(to, text, { profile: account.profile });
|
const result = await sendMessageZalouser(to, text, { profile: account.profile });
|
||||||
return {
|
return {
|
||||||
channel: "zalouser",
|
channel: "zalouser",
|
||||||
@@ -455,7 +403,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
|
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
|
||||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
|
||||||
const result = await sendMessageZalouser(to, text, {
|
const result = await sendMessageZalouser(to, text, {
|
||||||
profile: account.profile,
|
profile: account.profile,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
@@ -534,7 +482,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
const { monitorZalouserProvider } = await import("./monitor.js");
|
const { monitorZalouserProvider } = await import("./monitor.js");
|
||||||
return monitorZalouserProvider({
|
return monitorZalouserProvider({
|
||||||
account,
|
account,
|
||||||
config: ctx.cfg as CoreConfig,
|
config: ctx.cfg as ClawdbotConfig,
|
||||||
runtime: ctx.runtime,
|
runtime: ctx.runtime,
|
||||||
abortSignal: ctx.abortSignal,
|
abortSignal: ctx.abortSignal,
|
||||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
|
|
||||||
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
|
formatAgentEnvelope,
|
||||||
isControlCommandMessage,
|
isControlCommandMessage,
|
||||||
mergeAllowlist,
|
mergeAllowlist,
|
||||||
recordSessionMetaFromInbound,
|
recordSessionMetaFromInbound,
|
||||||
@@ -11,20 +12,19 @@ import {
|
|||||||
shouldComputeCommandAuthorized,
|
shouldComputeCommandAuthorized,
|
||||||
summarizeMapping,
|
summarizeMapping,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
|
|
||||||
import { sendMessageZalouser } from "./send.js";
|
import { sendMessageZalouser } from "./send.js";
|
||||||
import type {
|
import type {
|
||||||
CoreConfig,
|
|
||||||
ResolvedZalouserAccount,
|
ResolvedZalouserAccount,
|
||||||
ZcaFriend,
|
ZcaFriend,
|
||||||
ZcaGroup,
|
ZcaGroup,
|
||||||
ZcaMessage,
|
ZcaMessage,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
import { getZalouserRuntime } from "./runtime.js";
|
||||||
import { parseJsonOutput, runZca, runZcaStreaming } from "./zca.js";
|
import { parseJsonOutput, runZca, runZcaStreaming } from "./zca.js";
|
||||||
|
|
||||||
export type ZalouserMonitorOptions = {
|
export type ZalouserMonitorOptions = {
|
||||||
account: ResolvedZalouserAccount;
|
account: ResolvedZalouserAccount;
|
||||||
config: CoreConfig;
|
config: ClawdbotConfig;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
abortSignal: AbortSignal;
|
abortSignal: AbortSignal;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
@@ -55,8 +55,10 @@ function buildNameIndex<T>(
|
|||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
function logVerbose(deps: CoreChannelDeps, runtime: RuntimeEnv, message: string): void {
|
type ZalouserCoreRuntime = ReturnType<typeof getZalouserRuntime>;
|
||||||
if (deps.shouldLogVerbose()) {
|
|
||||||
|
function logVerbose(core: ZalouserCoreRuntime, runtime: RuntimeEnv, message: string): void {
|
||||||
|
if (core.logging.shouldLogVerbose()) {
|
||||||
runtime.log(`[zalouser] ${message}`);
|
runtime.log(`[zalouser] ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,8 +159,8 @@ function startZcaListener(
|
|||||||
async function processMessage(
|
async function processMessage(
|
||||||
message: ZcaMessage,
|
message: ZcaMessage,
|
||||||
account: ResolvedZalouserAccount,
|
account: ResolvedZalouserAccount,
|
||||||
config: CoreConfig,
|
config: ClawdbotConfig,
|
||||||
deps: CoreChannelDeps,
|
core: ZalouserCoreRuntime,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -176,13 +178,13 @@ async function processMessage(
|
|||||||
const groups = account.config.groups ?? {};
|
const groups = account.config.groups ?? {};
|
||||||
if (isGroup) {
|
if (isGroup) {
|
||||||
if (groupPolicy === "disabled") {
|
if (groupPolicy === "disabled") {
|
||||||
logVerbose(deps, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`);
|
logVerbose(core, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (groupPolicy === "allowlist") {
|
if (groupPolicy === "allowlist") {
|
||||||
const allowed = isGroupAllowed({ groupId: chatId, groupName, groups });
|
const allowed = isGroupAllowed({ groupId: chatId, groupName, groups });
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
logVerbose(deps, runtime, `zalouser: drop group ${chatId} (not allowlisted)`);
|
logVerbose(core, runtime, `zalouser: drop group ${chatId} (not allowlisted)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,7 +196,7 @@ async function processMessage(
|
|||||||
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
|
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
|
||||||
const storeAllowFrom =
|
const storeAllowFrom =
|
||||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
||||||
? await deps.readChannelAllowFromStore("zalouser").catch(() => [])
|
? await core.channel.pairing.readAllowFromStore("zalouser").catch(() => [])
|
||||||
: [];
|
: [];
|
||||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||||
@@ -208,7 +210,7 @@ async function processMessage(
|
|||||||
|
|
||||||
if (!isGroup) {
|
if (!isGroup) {
|
||||||
if (dmPolicy === "disabled") {
|
if (dmPolicy === "disabled") {
|
||||||
logVerbose(deps, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
|
logVerbose(core, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,18 +219,18 @@ async function processMessage(
|
|||||||
|
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
if (dmPolicy === "pairing") {
|
if (dmPolicy === "pairing") {
|
||||||
const { code, created } = await deps.upsertChannelPairingRequest({
|
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||||
channel: "zalouser",
|
channel: "zalouser",
|
||||||
id: senderId,
|
id: senderId,
|
||||||
meta: { name: senderName || undefined },
|
meta: { name: senderName || undefined },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (created) {
|
if (created) {
|
||||||
logVerbose(deps, runtime, `zalouser pairing request sender=${senderId}`);
|
logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`);
|
||||||
try {
|
try {
|
||||||
await sendMessageZalouser(
|
await sendMessageZalouser(
|
||||||
chatId,
|
chatId,
|
||||||
deps.buildPairingReply({
|
core.channel.pairing.buildPairingReply({
|
||||||
channel: "zalouser",
|
channel: "zalouser",
|
||||||
idLine: `Your Zalo user id: ${senderId}`,
|
idLine: `Your Zalo user id: ${senderId}`,
|
||||||
code,
|
code,
|
||||||
@@ -238,7 +240,7 @@ async function processMessage(
|
|||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
deps,
|
core,
|
||||||
runtime,
|
runtime,
|
||||||
`zalouser pairing reply failed for ${senderId}: ${String(err)}`,
|
`zalouser pairing reply failed for ${senderId}: ${String(err)}`,
|
||||||
);
|
);
|
||||||
@@ -246,7 +248,7 @@ async function processMessage(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
deps,
|
core,
|
||||||
runtime,
|
runtime,
|
||||||
`Blocked unauthorized zalouser sender ${senderId} (dmPolicy=${dmPolicy})`,
|
`Blocked unauthorized zalouser sender ${senderId} (dmPolicy=${dmPolicy})`,
|
||||||
);
|
);
|
||||||
@@ -257,13 +259,13 @@ async function processMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
|
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
|
||||||
logVerbose(deps, runtime, `zalouser: drop control command from unauthorized sender ${senderId}`);
|
logVerbose(core, runtime, `zalouser: drop control command from unauthorized sender ${senderId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const peer = isGroup ? { kind: "group" as const, id: chatId } : { kind: "group" as const, id: senderId };
|
const peer = isGroup ? { kind: "group" as const, id: chatId } : { kind: "group" as const, id: senderId };
|
||||||
|
|
||||||
const route = deps.resolveAgentRoute({
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
cfg: config,
|
cfg: config,
|
||||||
channel: "zalouser",
|
channel: "zalouser",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@@ -275,7 +277,7 @@ async function processMessage(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||||
const body = deps.formatAgentEnvelope({
|
const body = formatAgentEnvelope({
|
||||||
channel: "Zalo Personal",
|
channel: "Zalo Personal",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: timestamp ? timestamp * 1000 : undefined,
|
timestamp: timestamp ? timestamp * 1000 : undefined,
|
||||||
@@ -313,7 +315,7 @@ async function processMessage(
|
|||||||
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
|
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
await deps.dispatchReplyWithBufferedBlockDispatcher({
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg: config,
|
cfg: config,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
@@ -324,7 +326,7 @@ async function processMessage(
|
|||||||
chatId,
|
chatId,
|
||||||
isGroup,
|
isGroup,
|
||||||
runtime,
|
runtime,
|
||||||
deps,
|
core,
|
||||||
statusSink,
|
statusSink,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -343,10 +345,10 @@ async function deliverZalouserReply(params: {
|
|||||||
chatId: string;
|
chatId: string;
|
||||||
isGroup: boolean;
|
isGroup: boolean;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
deps: CoreChannelDeps;
|
core: ZalouserCoreRuntime;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { payload, profile, chatId, isGroup, runtime, deps, statusSink } = params;
|
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
|
||||||
|
|
||||||
const mediaList = payload.mediaUrls?.length
|
const mediaList = payload.mediaUrls?.length
|
||||||
? payload.mediaUrls
|
? payload.mediaUrls
|
||||||
@@ -360,7 +362,7 @@ async function deliverZalouserReply(params: {
|
|||||||
const caption = first ? payload.text : undefined;
|
const caption = first ? payload.text : undefined;
|
||||||
first = false;
|
first = false;
|
||||||
try {
|
try {
|
||||||
logVerbose(deps, runtime, `Sending media to ${chatId}`);
|
logVerbose(core, runtime, `Sending media to ${chatId}`);
|
||||||
await sendMessageZalouser(chatId, caption ?? "", {
|
await sendMessageZalouser(chatId, caption ?? "", {
|
||||||
profile,
|
profile,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
@@ -375,8 +377,8 @@ async function deliverZalouserReply(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.text) {
|
if (payload.text) {
|
||||||
const chunks = deps.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
|
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
|
||||||
logVerbose(deps, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
|
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
try {
|
try {
|
||||||
await sendMessageZalouser(chatId, chunk, { profile, isGroup });
|
await sendMessageZalouser(chatId, chunk, { profile, isGroup });
|
||||||
@@ -394,7 +396,7 @@ export async function monitorZalouserProvider(
|
|||||||
let { account, config } = options;
|
let { account, config } = options;
|
||||||
const { abortSignal, statusSink, runtime } = options;
|
const { abortSignal, statusSink, runtime } = options;
|
||||||
|
|
||||||
const deps = await loadCoreChannelDeps();
|
const core = getZalouserRuntime();
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let proc: ChildProcess | null = null;
|
let proc: ChildProcess | null = null;
|
||||||
let restartTimer: ReturnType<typeof setTimeout> | null = null;
|
let restartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -506,7 +508,7 @@ export async function monitorZalouserProvider(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logVerbose(
|
logVerbose(
|
||||||
deps,
|
core,
|
||||||
runtime,
|
runtime,
|
||||||
`[${account.accountId}] starting zca listener (profile=${account.profile})`,
|
`[${account.accountId}] starting zca listener (profile=${account.profile})`,
|
||||||
);
|
);
|
||||||
@@ -515,16 +517,16 @@ export async function monitorZalouserProvider(
|
|||||||
runtime,
|
runtime,
|
||||||
account.profile,
|
account.profile,
|
||||||
(msg) => {
|
(msg) => {
|
||||||
logVerbose(deps, runtime, `[${account.accountId}] inbound message`);
|
logVerbose(core, runtime, `[${account.accountId}] inbound message`);
|
||||||
statusSink?.({ lastInboundAt: Date.now() });
|
statusSink?.({ lastInboundAt: Date.now() });
|
||||||
processMessage(msg, account, config, deps, runtime, statusSink).catch((err) => {
|
processMessage(msg, account, config, core, runtime, statusSink).catch((err) => {
|
||||||
runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
|
runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
runtime.error(`[${account.accountId}] zca listener error: ${String(err)}`);
|
runtime.error(`[${account.accountId}] zca listener error: ${String(err)}`);
|
||||||
if (!stopped && !abortSignal.aborted) {
|
if (!stopped && !abortSignal.aborted) {
|
||||||
logVerbose(deps, runtime, `[${account.accountId}] restarting listener in 5s...`);
|
logVerbose(core, runtime, `[${account.accountId}] restarting listener in 5s...`);
|
||||||
restartTimer = setTimeout(startListener, 5000);
|
restartTimer = setTimeout(startListener, 5000);
|
||||||
} else {
|
} else {
|
||||||
resolveRunning?.();
|
resolveRunning?.();
|
||||||
|
|||||||
@@ -1,31 +1,35 @@
|
|||||||
import type {
|
import type {
|
||||||
ChannelOnboardingAdapter,
|
ChannelOnboardingAdapter,
|
||||||
ChannelOnboardingDmPolicy,
|
ChannelOnboardingDmPolicy,
|
||||||
|
ClawdbotConfig,
|
||||||
WizardPrompter,
|
WizardPrompter,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
import { promptChannelAccessConfig } from "clawdbot/plugin-sdk";
|
import {
|
||||||
|
addWildcardAllowFrom,
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
normalizeAccountId,
|
||||||
|
promptAccountId,
|
||||||
|
promptChannelAccessConfig,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
listZalouserAccountIds,
|
listZalouserAccountIds,
|
||||||
resolveDefaultZalouserAccountId,
|
resolveDefaultZalouserAccountId,
|
||||||
resolveZalouserAccountSync,
|
resolveZalouserAccountSync,
|
||||||
normalizeAccountId,
|
|
||||||
checkZcaAuthenticated,
|
checkZcaAuthenticated,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js";
|
import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js";
|
||||||
import { DEFAULT_ACCOUNT_ID, type CoreConfig, type ZcaGroup } from "./types.js";
|
import type { ZcaGroup } from "./types.js";
|
||||||
|
|
||||||
const channel = "zalouser" as const;
|
const channel = "zalouser" as const;
|
||||||
|
|
||||||
function setZalouserDmPolicy(
|
function setZalouserDmPolicy(
|
||||||
cfg: CoreConfig,
|
cfg: ClawdbotConfig,
|
||||||
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
|
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
|
||||||
): CoreConfig {
|
): ClawdbotConfig {
|
||||||
const allowFrom =
|
const allowFrom =
|
||||||
dmPolicy === "open"
|
dmPolicy === "open"
|
||||||
? [...(cfg.channels?.zalouser?.allowFrom ?? []), "*"].filter(
|
? addWildcardAllowFrom(cfg.channels?.zalouser?.allowFrom)
|
||||||
(v, i, a) => a.indexOf(v) === i,
|
|
||||||
)
|
|
||||||
: undefined;
|
: undefined;
|
||||||
return {
|
return {
|
||||||
...cfg,
|
...cfg,
|
||||||
@@ -37,7 +41,7 @@ function setZalouserDmPolicy(
|
|||||||
...(allowFrom ? { allowFrom } : {}),
|
...(allowFrom ? { allowFrom } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
|
async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
|
||||||
@@ -56,10 +60,10 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function promptZalouserAllowFrom(params: {
|
async function promptZalouserAllowFrom(params: {
|
||||||
cfg: CoreConfig;
|
cfg: ClawdbotConfig;
|
||||||
prompter: WizardPrompter;
|
prompter: WizardPrompter;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
}): Promise<CoreConfig> {
|
}): Promise<ClawdbotConfig> {
|
||||||
const { cfg, prompter, accountId } = params;
|
const { cfg, prompter, accountId } = params;
|
||||||
const resolved = resolveZalouserAccountSync({ cfg, accountId });
|
const resolved = resolveZalouserAccountSync({ cfg, accountId });
|
||||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||||
@@ -93,7 +97,7 @@ async function promptZalouserAllowFrom(params: {
|
|||||||
allowFrom: unique,
|
allowFrom: unique,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -114,14 +118,14 @@ async function promptZalouserAllowFrom(params: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setZalouserGroupPolicy(
|
function setZalouserGroupPolicy(
|
||||||
cfg: CoreConfig,
|
cfg: ClawdbotConfig,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
groupPolicy: "open" | "allowlist" | "disabled",
|
groupPolicy: "open" | "allowlist" | "disabled",
|
||||||
): CoreConfig {
|
): ClawdbotConfig {
|
||||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
return {
|
return {
|
||||||
...cfg,
|
...cfg,
|
||||||
@@ -133,7 +137,7 @@ function setZalouserGroupPolicy(
|
|||||||
groupPolicy,
|
groupPolicy,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...cfg,
|
...cfg,
|
||||||
@@ -152,14 +156,14 @@ function setZalouserGroupPolicy(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setZalouserGroupAllowlist(
|
function setZalouserGroupAllowlist(
|
||||||
cfg: CoreConfig,
|
cfg: ClawdbotConfig,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
groupKeys: string[],
|
groupKeys: string[],
|
||||||
): CoreConfig {
|
): ClawdbotConfig {
|
||||||
const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }]));
|
const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }]));
|
||||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
return {
|
return {
|
||||||
@@ -172,7 +176,7 @@ function setZalouserGroupAllowlist(
|
|||||||
groups,
|
groups,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...cfg,
|
...cfg,
|
||||||
@@ -191,11 +195,11 @@ function setZalouserGroupAllowlist(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveZalouserGroups(params: {
|
async function resolveZalouserGroups(params: {
|
||||||
cfg: CoreConfig;
|
cfg: ClawdbotConfig;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
entries: string[];
|
entries: string[];
|
||||||
}): Promise<Array<{ input: string; resolved: boolean; id?: string }>> {
|
}): Promise<Array<{ input: string; resolved: boolean; id?: string }>> {
|
||||||
@@ -226,65 +230,23 @@ async function resolveZalouserGroups(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function promptAccountId(params: {
|
|
||||||
cfg: CoreConfig;
|
|
||||||
prompter: WizardPrompter;
|
|
||||||
label: string;
|
|
||||||
currentId: string;
|
|
||||||
listAccountIds: (cfg: CoreConfig) => string[];
|
|
||||||
defaultAccountId: string;
|
|
||||||
}): Promise<string> {
|
|
||||||
const { cfg, prompter, label, currentId, listAccountIds, defaultAccountId } = params;
|
|
||||||
const existingIds = listAccountIds(cfg);
|
|
||||||
const options = [
|
|
||||||
...existingIds.map((id) => ({
|
|
||||||
value: id,
|
|
||||||
label: id === defaultAccountId ? `${id} (default)` : id,
|
|
||||||
})),
|
|
||||||
{ value: "__new__", label: "Create new account" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const selected = await prompter.select({
|
|
||||||
message: `${label} account`,
|
|
||||||
options,
|
|
||||||
initialValue: currentId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selected === "__new__") {
|
|
||||||
const newId = await prompter.text({
|
|
||||||
message: "New account ID",
|
|
||||||
placeholder: "work",
|
|
||||||
validate: (value) => {
|
|
||||||
const raw = String(value ?? "").trim().toLowerCase();
|
|
||||||
if (!raw) return "Required";
|
|
||||||
if (!/^[a-z0-9_-]+$/.test(raw)) return "Use lowercase alphanumeric, dash, or underscore";
|
|
||||||
if (existingIds.includes(raw)) return "Account already exists";
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return String(newId).trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||||
label: "Zalo Personal",
|
label: "Zalo Personal",
|
||||||
channel,
|
channel,
|
||||||
policyKey: "channels.zalouser.dmPolicy",
|
policyKey: "channels.zalouser.dmPolicy",
|
||||||
allowFromKey: "channels.zalouser.allowFrom",
|
allowFromKey: "channels.zalouser.allowFrom",
|
||||||
getCurrent: (cfg) => ((cfg as CoreConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
|
getCurrent: (cfg) => ((cfg as ClawdbotConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
|
||||||
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as CoreConfig, policy),
|
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as ClawdbotConfig, policy),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||||
channel,
|
channel,
|
||||||
dmPolicy,
|
dmPolicy,
|
||||||
getStatus: async ({ cfg }) => {
|
getStatus: async ({ cfg }) => {
|
||||||
const ids = listZalouserAccountIds(cfg as CoreConfig);
|
const ids = listZalouserAccountIds(cfg as ClawdbotConfig);
|
||||||
let configured = false;
|
let configured = false;
|
||||||
for (const accountId of ids) {
|
for (const accountId of ids) {
|
||||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
|
||||||
const isAuth = await checkZcaAuthenticated(account.profile);
|
const isAuth = await checkZcaAuthenticated(account.profile);
|
||||||
if (isAuth) {
|
if (isAuth) {
|
||||||
configured = true;
|
configured = true;
|
||||||
@@ -316,14 +278,14 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const zalouserOverride = accountOverrides.zalouser?.trim();
|
const zalouserOverride = accountOverrides.zalouser?.trim();
|
||||||
const defaultAccountId = resolveDefaultZalouserAccountId(cfg as CoreConfig);
|
const defaultAccountId = resolveDefaultZalouserAccountId(cfg as ClawdbotConfig);
|
||||||
let accountId = zalouserOverride
|
let accountId = zalouserOverride
|
||||||
? normalizeAccountId(zalouserOverride)
|
? normalizeAccountId(zalouserOverride)
|
||||||
: defaultAccountId;
|
: defaultAccountId;
|
||||||
|
|
||||||
if (shouldPromptAccountIds && !zalouserOverride) {
|
if (shouldPromptAccountIds && !zalouserOverride) {
|
||||||
accountId = await promptAccountId({
|
accountId = await promptAccountId({
|
||||||
cfg: cfg as CoreConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
prompter,
|
prompter,
|
||||||
label: "Zalo Personal",
|
label: "Zalo Personal",
|
||||||
currentId: accountId,
|
currentId: accountId,
|
||||||
@@ -332,7 +294,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let next = cfg as CoreConfig;
|
let next = cfg as ClawdbotConfig;
|
||||||
const account = resolveZalouserAccountSync({ cfg: next, accountId });
|
const account = resolveZalouserAccountSync({ cfg: next, accountId });
|
||||||
const alreadyAuthenticated = await checkZcaAuthenticated(account.profile);
|
const alreadyAuthenticated = await checkZcaAuthenticated(account.profile);
|
||||||
|
|
||||||
@@ -390,7 +352,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
profile: account.profile !== "default" ? account.profile : undefined,
|
profile: account.profile !== "default" ? account.profile : undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
} else {
|
} else {
|
||||||
next = {
|
next = {
|
||||||
...next,
|
...next,
|
||||||
@@ -409,7 +371,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as CoreConfig;
|
} as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (forceAllowFrom) {
|
if (forceAllowFrom) {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
export function setZalouserRuntime(next: PluginRuntime): void {
|
||||||
|
runtime = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getZalouserRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("Zalouser runtime not initialized");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
@@ -68,9 +68,6 @@ export type ListenOptions = CommonOptions & {
|
|||||||
prefix?: string;
|
prefix?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Channel plugin config types
|
|
||||||
export const DEFAULT_ACCOUNT_ID = "default";
|
|
||||||
|
|
||||||
export type ZalouserAccountConfig = {
|
export type ZalouserAccountConfig = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -95,14 +92,6 @@ export type ZalouserConfig = {
|
|||||||
accounts?: Record<string, ZalouserAccountConfig>;
|
accounts?: Record<string, ZalouserAccountConfig>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CoreConfig = {
|
|
||||||
channels?: {
|
|
||||||
zalouser?: ZalouserConfig;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ResolvedZalouserAccount = {
|
export type ResolvedZalouserAccount = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
"exports": {
|
||||||
|
".": "./dist/index.js",
|
||||||
|
"./plugin-sdk": "./dist/plugin-sdk/index.js",
|
||||||
|
"./plugin-sdk/*": "./dist/plugin-sdk/*"
|
||||||
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"clawdbot": "dist/entry.js"
|
"clawdbot": "dist/entry.js"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+20
@@ -238,6 +238,8 @@ importers:
|
|||||||
specifier: 3.14.5
|
specifier: 3.14.5
|
||||||
version: 3.14.5(typescript@5.9.3)
|
version: 3.14.5(typescript@5.9.3)
|
||||||
|
|
||||||
|
extensions/bluebubbles: {}
|
||||||
|
|
||||||
extensions/copilot-proxy: {}
|
extensions/copilot-proxy: {}
|
||||||
|
|
||||||
extensions/google-antigravity-auth: {}
|
extensions/google-antigravity-auth: {}
|
||||||
@@ -246,6 +248,9 @@ importers:
|
|||||||
|
|
||||||
extensions/matrix:
|
extensions/matrix:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
clawdbot:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../..
|
||||||
markdown-it:
|
markdown-it:
|
||||||
specifier: 14.1.0
|
specifier: 14.1.0
|
||||||
version: 14.1.0
|
version: 14.1.0
|
||||||
@@ -253,6 +258,12 @@ importers:
|
|||||||
specifier: 40.0.0
|
specifier: 40.0.0
|
||||||
version: 40.0.0
|
version: 40.0.0
|
||||||
|
|
||||||
|
extensions/memory-core:
|
||||||
|
dependencies:
|
||||||
|
clawdbot:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../..
|
||||||
|
|
||||||
extensions/msteams:
|
extensions/msteams:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@microsoft/agents-hosting':
|
'@microsoft/agents-hosting':
|
||||||
@@ -264,6 +275,9 @@ importers:
|
|||||||
'@microsoft/agents-hosting-extensions-teams':
|
'@microsoft/agents-hosting-extensions-teams':
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
|
clawdbot:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../..
|
||||||
express:
|
express:
|
||||||
specifier: ^5.2.1
|
specifier: ^5.2.1
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
@@ -285,6 +299,9 @@ importers:
|
|||||||
|
|
||||||
extensions/zalo:
|
extensions/zalo:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
clawdbot:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../..
|
||||||
undici:
|
undici:
|
||||||
specifier: 7.18.2
|
specifier: 7.18.2
|
||||||
version: 7.18.2
|
version: 7.18.2
|
||||||
@@ -294,6 +311,9 @@ importers:
|
|||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
specifier: 0.34.47
|
specifier: 0.34.47
|
||||||
version: 0.34.47
|
version: 0.34.47
|
||||||
|
clawdbot:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../..
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Loaded 100 of 289 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user