Compare commits
54 Commits
feature/me
...
fix/tool-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ace906539f | ||
|
|
f039fdf8e9 | ||
|
|
858a5f48d8 | ||
|
|
20c26eb303 | ||
|
|
f3ef609839 | ||
|
|
234fe5b5cd | ||
|
|
e944f21ec0 | ||
|
|
ee6e534ccb | ||
|
|
676d41d415 | ||
|
|
a3a4996adb | ||
|
|
b015c7e5ad | ||
|
|
4de3c3a028 | ||
|
|
b739a3897f | ||
|
|
c5e19f5c67 | ||
|
|
9241e21114 | ||
|
|
65bed815a8 | ||
|
|
d776cfb4e1 | ||
|
|
c6e7e1821b | ||
|
|
f76ab69612 | ||
|
|
889db137b8 | ||
|
|
9db682750d | ||
|
|
9809b47d45 | ||
|
|
68d79e56c2 | ||
|
|
d3862ae30a | ||
|
|
e49a2952d9 | ||
|
|
8b57f519c3 | ||
|
|
ddcc05f5f4 | ||
|
|
8c0e290db1 | ||
|
|
7bfc77db25 | ||
|
|
8d74578ceb | ||
|
|
f7123ec30a | ||
|
|
ad4f4388f4 | ||
|
|
2a86504723 | ||
|
|
de3b68740a | ||
|
|
efaa73f543 | ||
|
|
1589c73697 | ||
|
|
359d2af8a8 | ||
|
|
fa897e5dfe | ||
|
|
7fa8ae56cb | ||
|
|
ec27c813cc | ||
|
|
3b24fe639a | ||
|
|
e5cca6e432 | ||
|
|
ae0b4c4990 | ||
|
|
49bd2d96fa | ||
|
|
ca350fc66c | ||
|
|
30338ce1a7 | ||
|
|
faa94f0168 | ||
|
|
f5c84768ff | ||
|
|
df752d4706 | ||
|
|
c9c9516206 | ||
|
|
d3b15c6afa | ||
|
|
ebfeb7a6bf | ||
|
|
b594f5130d | ||
|
|
e2bb5eecf3 |
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -1,4 +0,0 @@
|
||||
[submodule "Peekaboo"]
|
||||
path = Peekaboo
|
||||
url = https://github.com/steipete/Peekaboo.git
|
||||
branch = main
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -2,6 +2,23 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.18-4
|
||||
|
||||
### Changes
|
||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
|
||||
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
|
||||
- Swabble: use the tagged Commander Swift package release.
|
||||
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
||||
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
||||
- Memory: add native Gemini embeddings provider for memory search. (#1151) — thanks @gumadeiras.
|
||||
|
||||
### Fixes
|
||||
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
||||
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151) — thanks @gumadeiras.
|
||||
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151) — thanks @gumadeiras.
|
||||
- Agents: surface tool failures when no assistant output is emitted. (#1175) — thanks @vrknetha.
|
||||
|
||||
## 2026.1.18-3
|
||||
|
||||
### Changes
|
||||
@@ -9,14 +26,21 @@ Docs: https://docs.clawd.bot
|
||||
- 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.
|
||||
- Plugins: add typed lifecycle hooks + vector memory plugin. (#1149) — thanks @radek-paclt.
|
||||
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
|
||||
- Nodes: add node daemon service install/status/start/stop/restart.
|
||||
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||
- 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.
|
||||
- Agents: auto-inject local image references for vision models and avoid reloading history images. (#1098) — thanks @tyler6204.
|
||||
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
||||
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
|
||||
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
|
||||
|
||||
### Fixes
|
||||
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
||||
- 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.
|
||||
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
|
||||
|
||||
## 2026.1.18-2
|
||||
|
||||
|
||||
1
Peekaboo
1
Peekaboo
Submodule Peekaboo deleted from 5c195f5e46
@@ -13,7 +13,7 @@ let package = Package(
|
||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../Peekaboo/Commander"),
|
||||
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
|
||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||
],
|
||||
targets: [
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
{
|
||||
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
|
||||
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "axorcist",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/AXorcist.git",
|
||||
"state" : {
|
||||
"revision" : "c75d06f7f93e264a9786edc2b78c04973061cb2f",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "commander",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Commander.git",
|
||||
"state" : {
|
||||
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
|
||||
"version" : "0.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -10,15 +28,6 @@
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "eventsource",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/mattt/eventsource.git",
|
||||
"state" : {
|
||||
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "menubarextraaccess",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -28,6 +37,15 @@
|
||||
"version" : "1.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "peekaboo",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Peekaboo.git",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "b2d0384d9f0f45b945d5f718f8a865bd574d83c2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sparkle",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -46,33 +64,6 @@
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-asn1.git",
|
||||
"state" : {
|
||||
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
|
||||
"version" : "1.5.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-async-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
||||
"state" : {
|
||||
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -82,24 +73,6 @@
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-configuration",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-configuration",
|
||||
"state" : {
|
||||
"revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749",
|
||||
"version" : "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-crypto",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-crypto.git",
|
||||
"state" : {
|
||||
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
|
||||
"version" : "4.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -118,24 +91,6 @@
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/modelcontextprotocol/swift-sdk.git",
|
||||
"state" : {
|
||||
"revision" : "c0407a0b52677cb395d824cac2879b963075ba8c",
|
||||
"version" : "0.10.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-service-lifecycle",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swift-server/swift-service-lifecycle",
|
||||
"state" : {
|
||||
"revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
|
||||
"version" : "2.9.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-subprocess",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -20,10 +20,9 @@ let package = Package(
|
||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"),
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
||||
.package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"),
|
||||
.package(path: "../shared/ClawdbotKit"),
|
||||
.package(path: "../../Swabble"),
|
||||
.package(path: "../../Peekaboo/Core/PeekabooCore"),
|
||||
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@@ -61,8 +60,8 @@ let package = Package(
|
||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||
.product(name: "Logging", package: "swift-log"),
|
||||
.product(name: "Sparkle", package: "Sparkle"),
|
||||
.product(name: "PeekabooBridge", package: "PeekabooCore"),
|
||||
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
||||
.product(name: "PeekabooBridge", package: "Peekaboo"),
|
||||
.product(name: "PeekabooAutomationKit", package: "Peekaboo"),
|
||||
],
|
||||
exclude: [
|
||||
"Resources/Info.plist",
|
||||
|
||||
@@ -162,7 +162,7 @@ enum ExecApprovalsStore {
|
||||
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 ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
}
|
||||
return decoded
|
||||
} catch {
|
||||
@@ -204,7 +204,7 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func resolve(agentId: String?) -> ExecApprovalsResolved {
|
||||
var file = self.ensureFile()
|
||||
let file = self.ensureFile()
|
||||
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||
let resolvedDefaults = ExecApprovalsResolvedDefaults(
|
||||
security: defaults.security ?? self.defaultSecurity,
|
||||
@@ -397,11 +397,32 @@ struct ExecCommandResolution: Sendable {
|
||||
let executableName: String
|
||||
let cwd: String?
|
||||
|
||||
static func resolve(
|
||||
command: [String],
|
||||
rawCommand: String?,
|
||||
cwd: String?,
|
||||
env: [String: String]?
|
||||
) -> ExecCommandResolution? {
|
||||
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
|
||||
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
|
||||
}
|
||||
return self.resolve(command: command, cwd: cwd, env: env)
|
||||
}
|
||||
|
||||
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
|
||||
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||
}
|
||||
|
||||
private static func resolveExecutable(
|
||||
rawExecutable: String,
|
||||
cwd: String?,
|
||||
env: [String: String]?
|
||||
) -> ExecCommandResolution? {
|
||||
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
|
||||
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
|
||||
let resolvedPath: String? = {
|
||||
if hasPathSeparator {
|
||||
@@ -419,6 +440,20 @@ struct ExecCommandResolution: Sendable {
|
||||
return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
|
||||
}
|
||||
|
||||
private static func parseFirstToken(_ command: String) -> String? {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard let first = trimmed.first else { return nil }
|
||||
if first == "\"" || first == "'" {
|
||||
let rest = trimmed.dropFirst()
|
||||
if let end = rest.firstIndex(of: first) {
|
||||
return String(rest[..<end])
|
||||
}
|
||||
return String(rest)
|
||||
}
|
||||
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
||||
}
|
||||
|
||||
private static func searchPaths(from env: [String: String]?) -> [String] {
|
||||
let raw = env?["PATH"]
|
||||
if let raw, !raw.isEmpty {
|
||||
@@ -439,6 +474,12 @@ enum ExecCommandFormatter {
|
||||
return "\"\(escaped)\""
|
||||
}.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func displayString(for argv: [String], rawCommand: String?) -> String {
|
||||
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
return self.displayString(for: argv)
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecAllowlistMatcher {
|
||||
@@ -522,7 +563,7 @@ struct ExecEventPayload: Codable, Sendable {
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.count <= maxChars { return trimmed }
|
||||
let suffix = trimmed.suffix(maxChars)
|
||||
return "… (truncated) \(suffix)"
|
||||
return "... (truncated) \(suffix)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,9 +157,10 @@ final class ExecApprovalsPromptServer {
|
||||
}
|
||||
}
|
||||
|
||||
private enum ExecApprovalsPromptPresenter {
|
||||
enum ExecApprovalsPromptPresenter {
|
||||
@MainActor
|
||||
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Allow this command?"
|
||||
@@ -205,7 +206,7 @@ private enum ExecApprovalsPromptPresenter {
|
||||
}
|
||||
}
|
||||
|
||||
private final class ExecApprovalsSocketServer {
|
||||
private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
|
||||
private let socketPath: String
|
||||
private let token: String
|
||||
|
||||
@@ -16,6 +16,10 @@ enum GatewayLaunchAgentManager {
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||
_ = bundlePath
|
||||
guard !CommandResolver.connectionModeIsRemote() else {
|
||||
self.logger.info("launchd change skipped (remote mode)")
|
||||
return nil
|
||||
}
|
||||
if enabled, self.isLaunchAgentWriteDisabled() {
|
||||
self.logger.info("launchd enable skipped (disable marker set)")
|
||||
return nil
|
||||
@@ -112,7 +116,9 @@ extension GatewayLaunchAgentManager {
|
||||
{
|
||||
let command = CommandResolver.clawdbotCommand(
|
||||
subcommand: "daemon",
|
||||
extraArgs: self.withJsonFlag(args))
|
||||
extraArgs: self.withJsonFlag(args),
|
||||
// Launchd management must always run locally, even if remote mode is configured.
|
||||
configRoot: ["gateway": ["mode": "local"]])
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||
|
||||
@@ -114,6 +114,9 @@ final class GatewayProcessManager {
|
||||
self.lastFailureReason = nil
|
||||
self.status = .stopped
|
||||
self.logger.info("gateway stop requested")
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
return
|
||||
}
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
Task {
|
||||
_ = await GatewayLaunchAgentManager.set(
|
||||
|
||||
@@ -432,6 +432,7 @@ actor MacNodeRuntime {
|
||||
guard !command.isEmpty else {
|
||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||
}
|
||||
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: params.rawCommand)
|
||||
|
||||
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
||||
@@ -444,7 +445,12 @@ actor MacNodeRuntime {
|
||||
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: self.mainSessionKey
|
||||
let runId = UUID().uuidString
|
||||
let resolution = ExecCommandResolution.resolve(command: command, cwd: params.cwd, env: params.env)
|
||||
let env = Self.sanitizedEnv(params.env)
|
||||
let resolution = ExecCommandResolution.resolve(
|
||||
command: command,
|
||||
rawCommand: params.rawCommand,
|
||||
cwd: params.cwd,
|
||||
env: env)
|
||||
let allowlistMatch = security == .allowlist
|
||||
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
||||
: nil
|
||||
@@ -463,7 +469,7 @@ actor MacNodeRuntime {
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
command: displayCommand,
|
||||
reason: "security=deny"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
@@ -477,12 +483,11 @@ actor MacNodeRuntime {
|
||||
return false
|
||||
}()
|
||||
|
||||
var approvedByAsk = false
|
||||
if requiresAsk {
|
||||
let decision = await ExecApprovalsSocketClient.requestDecision(
|
||||
socketPath: approvals.socketPath,
|
||||
token: approvals.token,
|
||||
request: ExecApprovalPromptRequest(
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
let decision: ExecApprovalDecision? = await ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: displayCommand,
|
||||
cwd: params.cwd,
|
||||
host: "node",
|
||||
security: security.rawValue,
|
||||
@@ -498,21 +503,40 @@ actor MacNodeRuntime {
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
command: displayCommand,
|
||||
reason: "user-denied"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied")
|
||||
case nil:
|
||||
if askFallback == .deny || (askFallback == .allowlist && allowlistMatch == nil && !skillAllow) {
|
||||
if askFallback == .full {
|
||||
approvedByAsk = true
|
||||
} else if askFallback == .allowlist {
|
||||
if allowlistMatch != nil || skillAllow {
|
||||
approvedByAsk = true
|
||||
} else {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "approval-required"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: approval required")
|
||||
}
|
||||
} else {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
command: displayCommand,
|
||||
reason: "approval-required"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
@@ -520,6 +544,7 @@ actor MacNodeRuntime {
|
||||
message: "SYSTEM_RUN_DENIED: approval required")
|
||||
}
|
||||
case .allowAlways?:
|
||||
approvedByAsk = true
|
||||
if security == .allowlist {
|
||||
let pattern = resolution?.resolvedPath ??
|
||||
resolution?.rawExecutable ??
|
||||
@@ -530,20 +555,33 @@ actor MacNodeRuntime {
|
||||
}
|
||||
}
|
||||
case .allowOnce?:
|
||||
break
|
||||
approvedByAsk = true
|
||||
}
|
||||
}
|
||||
|
||||
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "allowlist-miss"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: allowlist miss")
|
||||
}
|
||||
|
||||
if let match = allowlistMatch {
|
||||
ExecApprovalsStore.recordAllowlistUse(
|
||||
agentId: agentId,
|
||||
pattern: match.pattern,
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
command: displayCommand,
|
||||
resolvedPath: resolution?.resolvedPath)
|
||||
}
|
||||
|
||||
let env = Self.sanitizedEnv(params.env)
|
||||
|
||||
if params.needsScreenRecording == true {
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
@@ -554,7 +592,7 @@ actor MacNodeRuntime {
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
command: displayCommand,
|
||||
reason: "permission:screenRecording"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
@@ -570,20 +608,23 @@ actor MacNodeRuntime {
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: ExecCommandFormatter.displayString(for: command)))
|
||||
command: displayCommand))
|
||||
let result = await ShellExecutor.runDetailed(
|
||||
command: command,
|
||||
cwd: params.cwd,
|
||||
env: env,
|
||||
timeout: timeoutSec)
|
||||
let combined = [result.stdout, result.stderr, result.errorMessage].filter { !$0.isEmpty }.joined(separator: "\n")
|
||||
let combined = [result.stdout, result.stderr, result.errorMessage]
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
await self.emitExecEvent(
|
||||
"exec.finished",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
command: displayCommand,
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
|
||||
@@ -13,18 +13,16 @@ struct SystemRunSettingsView: View {
|
||||
Text("Exec approvals")
|
||||
.font(.body)
|
||||
Spacer(minLength: 0)
|
||||
if self.model.agentIds.count > 1 {
|
||||
Picker("Agent", selection: Binding(
|
||||
get: { self.model.selectedAgentId },
|
||||
set: { self.model.selectAgent($0) }))
|
||||
{
|
||||
ForEach(self.model.agentIds, id: \.self) { id in
|
||||
Text(id).tag(id)
|
||||
}
|
||||
Picker("Agent", selection: Binding(
|
||||
get: { self.model.selectedAgentId },
|
||||
set: { self.model.selectAgent($0) }))
|
||||
{
|
||||
ForEach(self.model.agentPickerIds, id: \.self) { id in
|
||||
Text(self.model.label(for: id)).tag(id)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 180, alignment: .trailing)
|
||||
}
|
||||
|
||||
Picker("", selection: self.$tab) {
|
||||
@@ -82,7 +80,9 @@ struct SystemRunSettingsView: View {
|
||||
.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.")
|
||||
Text(self.model.isDefaultsScope
|
||||
? "Defaults apply when an agent has no overrides. Ask controls prompt behavior; fallback is used when no companion UI is reachable."
|
||||
: "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)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -101,31 +101,37 @@ struct SystemRunSettingsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Add") {
|
||||
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !pattern.isEmpty else { return }
|
||||
self.model.addEntry(pattern)
|
||||
self.newPattern = ""
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
if self.model.entries.isEmpty {
|
||||
Text("No allowlisted commands yet.")
|
||||
if self.model.isDefaultsScope {
|
||||
Text("Allowlists are per-agent. Select an agent to edit its allowlist.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
||||
ExecAllowlistRow(
|
||||
entry: Binding(
|
||||
get: { self.model.entries[index] },
|
||||
set: { self.model.updateEntry($0, at: index) }),
|
||||
onRemove: { self.model.removeEntry(at: index) })
|
||||
HStack(spacing: 8) {
|
||||
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Add") {
|
||||
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !pattern.isEmpty else { return }
|
||||
self.model.addEntry(pattern)
|
||||
self.newPattern = ""
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
if self.model.entries.isEmpty {
|
||||
Text("No allowlisted commands yet.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
||||
ExecAllowlistRow(
|
||||
entry: Binding(
|
||||
get: { self.model.entries[index] },
|
||||
set: { self.model.updateEntry($0, at: index) }),
|
||||
onRemove: { self.model.removeEntry(at: index) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,8 +183,16 @@ struct ExecAllowlistRow: View {
|
||||
Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
|
||||
Text("Last used: \(lastUsedCommand)")
|
||||
}
|
||||
|
||||
if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
|
||||
Text("Last command: \(lastUsedCommand)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let lastResolvedPath = self.entry.lastResolvedPath, !lastResolvedPath.isEmpty {
|
||||
Text("Resolved path: \(lastResolvedPath)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -201,6 +215,7 @@ struct ExecAllowlistRow: View {
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ExecApprovalsSettingsModel {
|
||||
private static let defaultsScopeId = "__defaults__"
|
||||
var agentIds: [String] = []
|
||||
var selectedAgentId: String = "main"
|
||||
var defaultAgentId: String = "main"
|
||||
@@ -211,6 +226,19 @@ final class ExecApprovalsSettingsModel {
|
||||
var entries: [ExecAllowlistEntry] = []
|
||||
var skillBins: [String] = []
|
||||
|
||||
var agentPickerIds: [String] {
|
||||
[Self.defaultsScopeId] + self.agentIds
|
||||
}
|
||||
|
||||
var isDefaultsScope: Bool {
|
||||
self.selectedAgentId == Self.defaultsScopeId
|
||||
}
|
||||
|
||||
func label(for id: String) -> String {
|
||||
if id == Self.defaultsScopeId { return "Defaults" }
|
||||
return id
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
await self.refreshAgents()
|
||||
self.loadSettings(for: self.selectedAgentId)
|
||||
@@ -242,6 +270,9 @@ final class ExecApprovalsSettingsModel {
|
||||
}
|
||||
self.agentIds = ids
|
||||
self.defaultAgentId = defaultId ?? "main"
|
||||
if self.selectedAgentId == Self.defaultsScopeId {
|
||||
return
|
||||
}
|
||||
if !self.agentIds.contains(self.selectedAgentId) {
|
||||
self.selectedAgentId = self.defaultAgentId
|
||||
}
|
||||
@@ -254,6 +285,15 @@ final class ExecApprovalsSettingsModel {
|
||||
}
|
||||
|
||||
func loadSettings(for agentId: String) {
|
||||
if agentId == Self.defaultsScopeId {
|
||||
let defaults = ExecApprovalsStore.resolveDefaults()
|
||||
self.security = defaults.security
|
||||
self.ask = defaults.ask
|
||||
self.askFallback = defaults.askFallback
|
||||
self.autoAllowSkills = defaults.autoAllowSkills
|
||||
self.entries = []
|
||||
return
|
||||
}
|
||||
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
|
||||
self.security = resolved.agent.security
|
||||
self.ask = resolved.agent.ask
|
||||
@@ -265,36 +305,61 @@ final class ExecApprovalsSettingsModel {
|
||||
|
||||
func setSecurity(_ security: ExecSecurity) {
|
||||
self.security = security
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.security = security
|
||||
if self.isDefaultsScope {
|
||||
ExecApprovalsStore.updateDefaults { defaults in
|
||||
defaults.security = security
|
||||
}
|
||||
} else {
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.security = security
|
||||
}
|
||||
}
|
||||
self.syncQuickMode()
|
||||
}
|
||||
|
||||
func setAsk(_ ask: ExecAsk) {
|
||||
self.ask = ask
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.ask = ask
|
||||
if self.isDefaultsScope {
|
||||
ExecApprovalsStore.updateDefaults { defaults in
|
||||
defaults.ask = ask
|
||||
}
|
||||
} else {
|
||||
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
|
||||
if self.isDefaultsScope {
|
||||
ExecApprovalsStore.updateDefaults { defaults in
|
||||
defaults.askFallback = mode
|
||||
}
|
||||
} else {
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.askFallback = mode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setAutoAllowSkills(_ enabled: Bool) {
|
||||
self.autoAllowSkills = enabled
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.autoAllowSkills = enabled
|
||||
if self.isDefaultsScope {
|
||||
ExecApprovalsStore.updateDefaults { defaults in
|
||||
defaults.autoAllowSkills = enabled
|
||||
}
|
||||
} else {
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.autoAllowSkills = enabled
|
||||
}
|
||||
}
|
||||
Task { await self.refreshSkillBins(force: enabled) }
|
||||
}
|
||||
|
||||
func addEntry(_ pattern: String) {
|
||||
guard !self.isDefaultsScope else { return }
|
||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
|
||||
@@ -302,12 +367,14 @@ final class ExecApprovalsSettingsModel {
|
||||
}
|
||||
|
||||
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
|
||||
guard !self.isDefaultsScope else { return }
|
||||
guard self.entries.indices.contains(index) else { return }
|
||||
self.entries[index] = entry
|
||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||
}
|
||||
|
||||
func removeEntry(at index: Int) {
|
||||
guard !self.isDefaultsScope else { return }
|
||||
guard self.entries.indices.contains(index) else { return }
|
||||
self.entries.remove(at: index)
|
||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||
@@ -323,6 +390,10 @@ final class ExecApprovalsSettingsModel {
|
||||
}
|
||||
|
||||
private func syncQuickMode() {
|
||||
if self.isDefaultsScope {
|
||||
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
||||
return
|
||||
}
|
||||
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
|
||||
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
||||
}
|
||||
|
||||
@@ -1632,6 +1632,51 @@ public struct LogsTailResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ExecApprovalsGetParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct ExecApprovalsSetParams: Codable, Sendable {
|
||||
public let file: [String: AnyCodable]
|
||||
public let basehash: String?
|
||||
|
||||
public init(
|
||||
file: [String: AnyCodable],
|
||||
basehash: String?
|
||||
) {
|
||||
self.file = file
|
||||
self.basehash = basehash
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case file
|
||||
case basehash = "baseHash"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||
public let path: String
|
||||
public let exists: Bool
|
||||
public let hash: String
|
||||
public let file: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
path: String,
|
||||
exists: Bool,
|
||||
hash: String,
|
||||
file: [String: AnyCodable]
|
||||
) {
|
||||
self.path = path
|
||||
self.exists = exists
|
||||
self.hash = hash
|
||||
self.file = file
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case path
|
||||
case exists
|
||||
case hash
|
||||
case file
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatHistoryParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let limit: Int?
|
||||
|
||||
@@ -134,4 +134,27 @@ import Testing
|
||||
#expect(script.contains("CLI="))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func configRootLocalOverridesRemoteDefaults() async throws {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||
defaults.set("clawd@example.com:2222", forKey: remoteTargetKey)
|
||||
|
||||
let tmp = try makeTempDir()
|
||||
CommandResolver.setProjectRoot(tmp.path)
|
||||
|
||||
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
|
||||
try self.makeExec(at: clawdbotPath)
|
||||
|
||||
let cmd = CommandResolver.clawdbotCommand(
|
||||
subcommand: "daemon",
|
||||
defaults: defaults,
|
||||
configRoot: ["gateway": ["mode": "local"]])
|
||||
|
||||
#expect(cmd.first == clawdbotPath.path)
|
||||
#expect(cmd.count >= 2)
|
||||
if cmd.count >= 2 {
|
||||
#expect(cmd[1] == "daemon")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ public enum ClawdbotNotificationDelivery: String, Codable, Sendable {
|
||||
|
||||
public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||
public var command: [String]
|
||||
public var rawCommand: String?
|
||||
public var cwd: String?
|
||||
public var env: [String: String]?
|
||||
public var timeoutMs: Int?
|
||||
@@ -29,6 +30,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||
|
||||
public init(
|
||||
command: [String],
|
||||
rawCommand: String? = nil,
|
||||
cwd: String? = nil,
|
||||
env: [String: String]? = nil,
|
||||
timeoutMs: Int? = nil,
|
||||
@@ -37,6 +39,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||
sessionKey: String? = nil)
|
||||
{
|
||||
self.command = command
|
||||
self.rawCommand = rawCommand
|
||||
self.cwd = cwd
|
||||
self.env = env
|
||||
self.timeoutMs = timeoutMs
|
||||
|
||||
194
docs.acp.md
Normal file
194
docs.acp.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Clawdbot ACP Bridge
|
||||
|
||||
This document describes how the Clawdbot ACP (Agent Client Protocol) bridge works,
|
||||
how it maps ACP sessions to Gateway sessions, and how IDEs should invoke it.
|
||||
|
||||
## Overview
|
||||
|
||||
`clawdbot acp` exposes an ACP agent over stdio and forwards prompts to a running
|
||||
Clawdbot Gateway over WebSocket. It keeps ACP session ids mapped to Gateway
|
||||
session keys so IDEs can reconnect to the same agent transcript or reset it on
|
||||
request.
|
||||
|
||||
Key goals:
|
||||
|
||||
- Minimal ACP surface area (stdio, NDJSON).
|
||||
- Stable session mapping across reconnects.
|
||||
- Works with existing Gateway session store (list/resolve/reset).
|
||||
- Safe defaults (isolated ACP session keys by default).
|
||||
|
||||
## How can I use this
|
||||
|
||||
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
|
||||
drive a Clawdbot Gateway session.
|
||||
|
||||
Quick steps:
|
||||
|
||||
1. Run a Gateway (local or remote).
|
||||
2. Configure the Gateway target (`gateway.remote.url` + auth) or pass flags.
|
||||
3. Point the IDE to run `clawdbot acp` over stdio.
|
||||
|
||||
Example config:
|
||||
|
||||
```bash
|
||||
clawdbot config set gateway.remote.url wss://gateway-host:18789
|
||||
clawdbot config set gateway.remote.token <token>
|
||||
```
|
||||
|
||||
Example run:
|
||||
|
||||
```bash
|
||||
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
||||
```
|
||||
|
||||
## Selecting agents
|
||||
|
||||
ACP does not pick agents directly. It routes by the Gateway session key.
|
||||
|
||||
Use agent-scoped session keys to target a specific agent:
|
||||
|
||||
```bash
|
||||
clawdbot acp --session agent:main:main
|
||||
clawdbot acp --session agent:design:main
|
||||
clawdbot acp --session agent:qa:bug-123
|
||||
```
|
||||
|
||||
Each ACP session maps to a single Gateway session key. One agent can have many
|
||||
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
|
||||
the key or label.
|
||||
|
||||
## Zed editor setup
|
||||
|
||||
Add a custom ACP agent in `~/.config/zed/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"Clawdbot ACP": {
|
||||
"type": "custom",
|
||||
"command": "clawdbot",
|
||||
"args": ["acp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To target a specific Gateway or agent:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"Clawdbot ACP": {
|
||||
"type": "custom",
|
||||
"command": "clawdbot",
|
||||
"args": [
|
||||
"acp",
|
||||
"--url", "wss://gateway-host:18789",
|
||||
"--token", "<token>",
|
||||
"--session", "agent:design:main"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
|
||||
|
||||
## Execution Model
|
||||
|
||||
- ACP client spawns `clawdbot acp` and speaks ACP messages over stdio.
|
||||
- The bridge connects to the Gateway using existing auth config (or CLI flags).
|
||||
- ACP `prompt` translates to Gateway `chat.send`.
|
||||
- Gateway streaming events are translated back into ACP streaming events.
|
||||
- ACP `cancel` maps to Gateway `chat.abort` for the active run.
|
||||
|
||||
## Session Mapping
|
||||
|
||||
By default each ACP session is mapped to a dedicated Gateway session key:
|
||||
|
||||
- `acp:<uuid>` unless overridden.
|
||||
|
||||
You can override or reuse sessions in two ways:
|
||||
|
||||
1) CLI defaults
|
||||
|
||||
```bash
|
||||
clawdbot acp --session agent:main:main
|
||||
clawdbot acp --session-label "support inbox"
|
||||
clawdbot acp --reset-session
|
||||
```
|
||||
|
||||
2) ACP metadata per session
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"sessionKey": "agent:main:main",
|
||||
"sessionLabel": "support inbox",
|
||||
"resetSession": true,
|
||||
"requireExisting": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `sessionKey`: direct Gateway session key.
|
||||
- `sessionLabel`: resolve an existing session by label.
|
||||
- `resetSession`: mint a new transcript for the key before first use.
|
||||
- `requireExisting`: fail if the key/label does not exist.
|
||||
|
||||
### Session Listing
|
||||
|
||||
ACP `listSessions` maps to Gateway `sessions.list` and returns a filtered
|
||||
summary suitable for IDE session pickers. `_meta.limit` can cap the number of
|
||||
sessions returned.
|
||||
|
||||
## Prompt Translation
|
||||
|
||||
ACP prompt inputs are converted into a Gateway `chat.send`:
|
||||
|
||||
- `text` and `resource` blocks become prompt text.
|
||||
- `resource_link` with image mime types become attachments.
|
||||
- The working directory can be prefixed into the prompt (default on, can be
|
||||
disabled with `--no-prefix-cwd`).
|
||||
|
||||
Gateway streaming events are translated into ACP `message` and `tool_call`
|
||||
updates. Terminal Gateway states map to ACP `done` with stop reasons:
|
||||
|
||||
- `complete` -> `stop`
|
||||
- `aborted` -> `cancel`
|
||||
- `error` -> `error`
|
||||
|
||||
## Auth + Gateway Discovery
|
||||
|
||||
`clawdbot acp` resolves the Gateway URL and auth from CLI flags or config:
|
||||
|
||||
- `--url` / `--token` / `--password` take precedence.
|
||||
- Otherwise use configured `gateway.remote.*` settings.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- ACP sessions are stored in memory for the bridge process lifetime.
|
||||
- Gateway session state is persisted by the Gateway itself.
|
||||
- `--verbose` logs ACP/Gateway bridge events to stderr (never stdout).
|
||||
- ACP runs can be canceled and the active run id is tracked per session.
|
||||
|
||||
## Compatibility
|
||||
|
||||
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x).
|
||||
- Works with ACP clients that implement `initialize`, `newSession`,
|
||||
`loadSession`, `prompt`, `cancel`, and `listSessions`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: `src/acp/session.test.ts` covers run id lifecycle.
|
||||
- Full gate: `pnpm lint && pnpm build && pnpm test && pnpm docs:build`.
|
||||
|
||||
## Related Docs
|
||||
|
||||
- CLI usage: `docs/cli/acp.md`
|
||||
- Session model: `docs/concepts/session.md`
|
||||
- Session management internals: `docs/reference/session-management-compaction.md`
|
||||
166
docs/cli/acp.md
Normal file
166
docs/cli/acp.md
Normal file
@@ -0,0 +1,166 @@
|
||||
---
|
||||
summary: "Run the ACP bridge for IDE integrations"
|
||||
read_when:
|
||||
- Setting up ACP-based IDE integrations
|
||||
- Debugging ACP session routing to the Gateway
|
||||
---
|
||||
|
||||
# acp
|
||||
|
||||
Run the ACP (Agent Client Protocol) bridge that talks to a Clawdbot Gateway.
|
||||
|
||||
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
|
||||
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
clawdbot acp
|
||||
|
||||
# Remote Gateway
|
||||
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
||||
|
||||
# Attach to an existing session key
|
||||
clawdbot acp --session agent:main:main
|
||||
|
||||
# Attach by label (must already exist)
|
||||
clawdbot acp --session-label "support inbox"
|
||||
|
||||
# Reset the session key before the first prompt
|
||||
clawdbot acp --session agent:main:main --reset-session
|
||||
```
|
||||
|
||||
## ACP client (debug)
|
||||
|
||||
Use the built-in ACP client to sanity-check the bridge without an IDE.
|
||||
It spawns the ACP bridge and lets you type prompts interactively.
|
||||
|
||||
```bash
|
||||
clawdbot acp client
|
||||
|
||||
# Point the spawned bridge at a remote Gateway
|
||||
clawdbot acp client --server-args --url wss://gateway-host:18789 --token <token>
|
||||
|
||||
# Override the server command (default: clawdbot)
|
||||
clawdbot acp client --server "node" --server-args dist/entry.js acp --url ws://127.0.0.1:19001
|
||||
```
|
||||
|
||||
## How to use this
|
||||
|
||||
Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want
|
||||
it to drive a Clawdbot Gateway session.
|
||||
|
||||
1. Ensure the Gateway is running (local or remote).
|
||||
2. Configure the Gateway target (config or flags).
|
||||
3. Point your IDE to run `clawdbot acp` over stdio.
|
||||
|
||||
Example config (persisted):
|
||||
|
||||
```bash
|
||||
clawdbot config set gateway.remote.url wss://gateway-host:18789
|
||||
clawdbot config set gateway.remote.token <token>
|
||||
```
|
||||
|
||||
Example direct run (no config write):
|
||||
|
||||
```bash
|
||||
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
||||
```
|
||||
|
||||
## Selecting agents
|
||||
|
||||
ACP does not pick agents directly. It routes by the Gateway session key.
|
||||
|
||||
Use agent-scoped session keys to target a specific agent:
|
||||
|
||||
```bash
|
||||
clawdbot acp --session agent:main:main
|
||||
clawdbot acp --session agent:design:main
|
||||
clawdbot acp --session agent:qa:bug-123
|
||||
```
|
||||
|
||||
Each ACP session maps to a single Gateway session key. One agent can have many
|
||||
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
|
||||
the key or label.
|
||||
|
||||
## Zed editor setup
|
||||
|
||||
Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zed’s Settings UI):
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"Clawdbot ACP": {
|
||||
"type": "custom",
|
||||
"command": "clawdbot",
|
||||
"args": ["acp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To target a specific Gateway or agent:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"Clawdbot ACP": {
|
||||
"type": "custom",
|
||||
"command": "clawdbot",
|
||||
"args": [
|
||||
"acp",
|
||||
"--url", "wss://gateway-host:18789",
|
||||
"--token", "<token>",
|
||||
"--session", "agent:design:main"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
|
||||
|
||||
## Session mapping
|
||||
|
||||
By default, ACP sessions get an isolated Gateway session key with an `acp:` prefix.
|
||||
To reuse a known session, pass a session key or label:
|
||||
|
||||
- `--session <key>`: use a specific Gateway session key.
|
||||
- `--session-label <label>`: resolve an existing session by label.
|
||||
- `--reset-session`: mint a fresh session id for that key (same key, new transcript).
|
||||
|
||||
If your ACP client supports metadata, you can override per session:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"sessionKey": "agent:main:main",
|
||||
"sessionLabel": "support inbox",
|
||||
"resetSession": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Learn more about session keys at [/concepts/session](/concepts/session).
|
||||
|
||||
## Options
|
||||
|
||||
- `--url <url>`: Gateway WebSocket URL (defaults to gateway.remote.url when configured).
|
||||
- `--token <token>`: Gateway auth token.
|
||||
- `--password <password>`: Gateway auth password.
|
||||
- `--session <key>`: default session key.
|
||||
- `--session-label <label>`: default session label to resolve.
|
||||
- `--require-existing`: fail if the session key/label does not exist.
|
||||
- `--reset-session`: reset the session key before first use.
|
||||
- `--no-prefix-cwd`: do not prefix prompts with the working directory.
|
||||
- `--verbose, -v`: verbose logging to stderr.
|
||||
|
||||
### `acp client` options
|
||||
|
||||
- `--cwd <dir>`: working directory for the ACP session.
|
||||
- `--server <command>`: ACP server command (default: `clawdbot`).
|
||||
- `--server-args <args...>`: extra arguments passed to the ACP server.
|
||||
- `--server-verbose`: enable verbose logging on the ACP server.
|
||||
- `--verbose, -v`: verbose client logging.
|
||||
@@ -15,6 +15,7 @@ the configure wizard (same as `clawdbot configure`).
|
||||
clawdbot config get browser.executablePath
|
||||
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
|
||||
clawdbot config set agents.defaults.heartbeat.every "2h"
|
||||
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
|
||||
clawdbot config unset tools.web.search.apiKey
|
||||
```
|
||||
|
||||
@@ -27,6 +28,13 @@ clawdbot config get agents.defaults.workspace
|
||||
clawdbot config get agents.list[0].id
|
||||
```
|
||||
|
||||
Use the agent list index to target a specific agent:
|
||||
|
||||
```bash
|
||||
clawdbot config get agents.list
|
||||
clawdbot config set agents.list[1].tools.exec.node "node-id-or-name"
|
||||
```
|
||||
|
||||
## Values
|
||||
|
||||
Values are parsed as JSON5 when possible; otherwise they are treated as strings.
|
||||
|
||||
@@ -23,6 +23,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`message`](/cli/message)
|
||||
- [`agent`](/cli/agent)
|
||||
- [`agents`](/cli/agents)
|
||||
- [`acp`](/cli/acp)
|
||||
- [`status`](/cli/status)
|
||||
- [`health`](/cli/health)
|
||||
- [`sessions`](/cli/sessions)
|
||||
@@ -32,6 +33,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`models`](/cli/models)
|
||||
- [`memory`](/cli/memory)
|
||||
- [`nodes`](/cli/nodes)
|
||||
- [`node`](/cli/node)
|
||||
- [`sandbox`](/cli/sandbox)
|
||||
- [`tui`](/cli/tui)
|
||||
- [`browser`](/cli/browser)
|
||||
@@ -125,6 +127,7 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
list
|
||||
add
|
||||
delete
|
||||
acp
|
||||
status
|
||||
health
|
||||
sessions
|
||||
@@ -168,21 +171,15 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
runs
|
||||
run
|
||||
nodes
|
||||
status
|
||||
describe
|
||||
list
|
||||
pending
|
||||
approve
|
||||
reject
|
||||
rename
|
||||
invoke
|
||||
run
|
||||
notify
|
||||
camera list|snap|clip
|
||||
canvas snapshot|present|hide|navigate|eval
|
||||
canvas a2ui push|reset
|
||||
screen record
|
||||
location get
|
||||
node
|
||||
start
|
||||
daemon
|
||||
status
|
||||
install
|
||||
uninstall
|
||||
start
|
||||
stop
|
||||
restart
|
||||
browser
|
||||
status
|
||||
start
|
||||
@@ -506,6 +503,11 @@ Options:
|
||||
- `--force`
|
||||
- `--json`
|
||||
|
||||
### `acp`
|
||||
Run the ACP bridge that connects IDEs to the Gateway.
|
||||
|
||||
See [`acp`](/cli/acp) for full options and examples.
|
||||
|
||||
### `status`
|
||||
Show linked session health and recent recipients.
|
||||
|
||||
@@ -772,6 +774,20 @@ Subcommands:
|
||||
|
||||
All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
|
||||
|
||||
## Node host
|
||||
|
||||
`node` runs a **headless node host** or manages it as a background service. See
|
||||
[`clawdbot node`](/cli/node).
|
||||
|
||||
Subcommands:
|
||||
- `node start --host <gateway-host> --port 18790`
|
||||
- `node daemon status`
|
||||
- `node daemon install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
||||
- `node daemon uninstall`
|
||||
- `node daemon start`
|
||||
- `node daemon stop`
|
||||
- `node daemon restart`
|
||||
|
||||
## Nodes
|
||||
|
||||
`nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).
|
||||
@@ -788,7 +804,7 @@ Subcommands:
|
||||
- `nodes reject <requestId>`
|
||||
- `nodes rename --node <id|name|ip> --name <displayName>`
|
||||
- `nodes invoke --node <id|name|ip> --command <command> [--params <json>] [--invoke-timeout <ms>] [--idempotency-key <key>]`
|
||||
- `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac only)
|
||||
- `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac node or headless node host)
|
||||
- `nodes notify --node <id|name|ip> [--title <text>] [--body <text>] [--sound <name>] [--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>] [--invoke-timeout <ms>]` (mac only)
|
||||
|
||||
Camera:
|
||||
|
||||
@@ -28,3 +28,5 @@ clawdbot memory search "release checklist"
|
||||
## Options
|
||||
|
||||
- `--verbose`: emit debug logs during memory probes and indexing.
|
||||
- `--index-mode auto|batch|direct`: override batch usage when indexing (`direct` favors speed; `batch` favors OpenAI Batch pricing).
|
||||
- `--progress auto|line|log|none`: progress output mode (`log` prints updates even without a TTY).
|
||||
|
||||
85
docs/cli/node.md
Normal file
85
docs/cli/node.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot node` (headless node host)"
|
||||
read_when:
|
||||
- Running the headless node host
|
||||
- Pairing a non-macOS node for system.run
|
||||
---
|
||||
|
||||
# `clawdbot node`
|
||||
|
||||
Run a **headless node host** that connects to the Gateway bridge and exposes
|
||||
`system.run` / `system.which` on this machine.
|
||||
|
||||
## Why use a node host?
|
||||
|
||||
Use a node host when you want agents to **run commands on other machines** in your
|
||||
network without installing a full macOS companion app there.
|
||||
|
||||
Common use cases:
|
||||
- Run commands on remote Linux/Windows boxes (build servers, lab machines, NAS).
|
||||
- Keep exec **sandboxed** on the gateway, but delegate approved runs to other hosts.
|
||||
- Provide a lightweight, headless execution target for automation or CI nodes.
|
||||
|
||||
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
||||
node host, so you can keep command access scoped and explicit.
|
||||
|
||||
## Start (foreground)
|
||||
|
||||
```bash
|
||||
clawdbot node start --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
||||
- `--port <port>`: Gateway bridge port (default: `18790`)
|
||||
- `--tls`: Use TLS for the bridge connection
|
||||
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
||||
- `--node-id <id>`: Override node id (clears pairing token)
|
||||
- `--display-name <name>`: Override the node display name
|
||||
|
||||
## Daemon (background service)
|
||||
|
||||
Install a headless node host as a user service.
|
||||
|
||||
```bash
|
||||
clawdbot node daemon install --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
||||
- `--port <port>`: Gateway bridge port (default: `18790`)
|
||||
- `--tls`: Use TLS for the bridge connection
|
||||
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
||||
- `--node-id <id>`: Override node id (clears pairing token)
|
||||
- `--display-name <name>`: Override the node display name
|
||||
- `--runtime <runtime>`: Service runtime (`node` or `bun`)
|
||||
- `--force`: Reinstall/overwrite if already installed
|
||||
|
||||
Manage the service:
|
||||
|
||||
```bash
|
||||
clawdbot node daemon status
|
||||
clawdbot node daemon start
|
||||
clawdbot node daemon stop
|
||||
clawdbot node daemon restart
|
||||
clawdbot node daemon uninstall
|
||||
```
|
||||
|
||||
## Pairing
|
||||
|
||||
The first connection creates a pending node pair request on the Gateway.
|
||||
Approve it via:
|
||||
|
||||
```bash
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
The node host stores its node id + token in `~/.clawdbot/node.json`.
|
||||
|
||||
## Exec approvals
|
||||
|
||||
`system.run` is gated by local exec approvals:
|
||||
|
||||
- `~/.clawdbot/exec-approvals.json`
|
||||
- [Exec approvals](/tools/exec-approvals)
|
||||
@@ -89,7 +89,26 @@ OAuth only covers chat/completions and does **not** satisfy embeddings for
|
||||
memory search. When using a custom OpenAI-compatible endpoint, set
|
||||
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
||||
|
||||
If you want to use a **custom OpenAI-compatible endpoint** (like Gemini, OpenRouter, or a proxy),
|
||||
If you want to use **Gemini embeddings** directly, set the provider to `gemini`:
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "gemini",
|
||||
model: "gemini-embedding-001", // default
|
||||
remote: {
|
||||
apiKey: "${GEMINI_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Gemini uses `GEMINI_API_KEY` (or `models.providers.google.apiKey`). Override
|
||||
`memorySearch.remote.baseUrl` to point at a custom Gemini-compatible endpoint.
|
||||
|
||||
If you want to use a **custom OpenAI-compatible endpoint** (like OpenRouter or a proxy),
|
||||
you can use the `remote` configuration:
|
||||
|
||||
```json5
|
||||
@@ -99,8 +118,8 @@ agents: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
remote: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
apiKey: "YOUR_GEMINI_API_KEY",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
apiKey: "YOUR_PROXY_KEY",
|
||||
headers: { "X-Custom-Header": "value" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,11 @@ It does **not** rotate on every request. The pinned profile is reused until:
|
||||
Manual selection via `/model …@<profileId>` sets a **user override** for that session
|
||||
and is not auto‑rotated until a new session starts.
|
||||
|
||||
Auto‑pinned profiles (selected by the session router) are treated as a **preference**:
|
||||
they are tried first, but Clawdbot may rotate to another profile on rate limits/timeouts.
|
||||
User‑pinned profiles stay locked to that profile; if it fails and model fallbacks
|
||||
are configured, Clawdbot moves to the next model instead of switching profiles.
|
||||
|
||||
### Why OAuth can “look lost”
|
||||
|
||||
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
|
||||
|
||||
@@ -77,6 +77,7 @@ What this does:
|
||||
- Seeds the workspace files if missing:
|
||||
`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`.
|
||||
- Default identity: **C3‑PO** (protocol droid).
|
||||
- Skips channel providers in dev mode (`CLAWDBOT_SKIP_CHANNELS=1`).
|
||||
|
||||
Reset flow (fresh start):
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
|
||||
## Frames
|
||||
|
||||
Client → Gateway:
|
||||
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake)
|
||||
- `event`: node signals (voice transcript, agent request, chat subscribe)
|
||||
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake, skills.bins)
|
||||
- `event`: node signals (voice transcript, agent request, chat subscribe, exec lifecycle)
|
||||
|
||||
Gateway → Client:
|
||||
- `invoke` / `invoke-res`: node commands (`canvas.*`, `camera.*`, `screen.record`,
|
||||
@@ -57,6 +57,18 @@ Gateway → Client:
|
||||
|
||||
Exact allowlist is enforced in `src/gateway/server-bridge.ts`.
|
||||
|
||||
## Exec lifecycle events
|
||||
|
||||
Nodes can emit `exec.started`, `exec.finished`, or `exec.denied` events to surface
|
||||
system.run activity. These are mapped to system events in the gateway.
|
||||
|
||||
Payload fields (all optional unless noted):
|
||||
- `sessionKey` (required): agent session to receive the system event.
|
||||
- `runId`: unique exec id for grouping.
|
||||
- `command`: raw or formatted command string.
|
||||
- `exitCode`, `timedOut`, `success`, `output`: completion details (finished only).
|
||||
- `reason`: denial reason (denied only).
|
||||
|
||||
## Tailnet usage
|
||||
|
||||
- Bind the bridge to a tailnet IP: `bridge.bind: "tailnet"` in
|
||||
|
||||
@@ -261,10 +261,9 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
ackMaxChars: 300
|
||||
},
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-004",
|
||||
provider: "gemini",
|
||||
model: "gemini-embedding-001",
|
||||
remote: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
apiKey: "${GEMINI_API_KEY}"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -65,8 +65,8 @@ stronger isolation between agents, run them under separate OS users or separate
|
||||
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
|
||||
|
||||
- Requires node pairing (approval + token).
|
||||
- Controlled on the Mac via **Settings → "Node Run Commands"**: "Always Ask" (default), "Always Allow", or "Never".
|
||||
- If you don’t want remote execution, set the policy to "Never" and remove node pairing for that Mac.
|
||||
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
|
||||
- If you don’t want remote execution, set security to **deny** and remove node pairing for that Mac.
|
||||
|
||||
## Dynamic skills (watcher / remote nodes)
|
||||
|
||||
|
||||
@@ -147,9 +147,10 @@ Notes:
|
||||
- The permission prompt must be accepted on the Android device before the capability is advertised.
|
||||
- Wi-Fi-only devices without telephony will not advertise `sms.send`.
|
||||
|
||||
## System commands (mac node)
|
||||
## System commands (node host / mac node)
|
||||
|
||||
The macOS node exposes `system.run` and `system.notify`.
|
||||
The macOS node exposes `system.run` and `system.notify`. The headless node host
|
||||
exposes `system.run` and `system.which`.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -163,12 +164,58 @@ Notes:
|
||||
- `system.notify` respects notification permission state on the macOS app.
|
||||
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
|
||||
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
|
||||
- `system.run` is gated by the macOS app policy (Settings → "Node Run Commands"): "Always Ask" prompts per command, "Always Allow" runs without prompts, and "Never" disables the tool. Denied prompts return `SYSTEM_RUN_DENIED`; disabled returns `SYSTEM_RUN_DISABLED`.
|
||||
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
|
||||
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
|
||||
- On headless node host, `system.run` is gated by exec approvals (`~/.clawdbot/exec-approvals.json`).
|
||||
|
||||
## Exec node binding
|
||||
|
||||
When multiple nodes are available, you can bind exec to a specific node.
|
||||
This sets the default node for `exec host=node` (and can be overridden per agent).
|
||||
|
||||
Global default:
|
||||
|
||||
```bash
|
||||
clawdbot config set tools.exec.node "node-id-or-name"
|
||||
```
|
||||
|
||||
Per-agent override:
|
||||
|
||||
```bash
|
||||
clawdbot config get agents.list
|
||||
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
|
||||
```
|
||||
|
||||
Unset to allow any node:
|
||||
|
||||
```bash
|
||||
clawdbot config unset tools.exec.node
|
||||
clawdbot config unset agents.list[0].tools.exec.node
|
||||
```
|
||||
|
||||
## Permissions map
|
||||
|
||||
Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted).
|
||||
|
||||
## Headless node host (cross-platform)
|
||||
|
||||
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
|
||||
bridge and exposes `system.run` / `system.which`. This is useful on Linux/Windows
|
||||
or for running a minimal node alongside a server.
|
||||
|
||||
Start it:
|
||||
|
||||
```bash
|
||||
clawdbot node start --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Pairing is still required (the Gateway will show a node approval prompt).
|
||||
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
|
||||
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
|
||||
(see [Exec approvals](/tools/exec-approvals)).
|
||||
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
|
||||
|
||||
## Mac node mode
|
||||
|
||||
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
|
||||
|
||||
@@ -14,15 +14,7 @@ Before building the app, ensure you have the following installed:
|
||||
1. **Xcode 26.2+**: Required for Swift development.
|
||||
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
|
||||
|
||||
## 1. Initialize Submodules
|
||||
|
||||
Clawdbot depends on several submodules (like `Peekaboo`). You must initialize these recursively:
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
## 2. Install Dependencies
|
||||
## 1. Install Dependencies
|
||||
|
||||
Install the project-wide dependencies:
|
||||
|
||||
@@ -30,7 +22,7 @@ Install the project-wide dependencies:
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## 3. Build and Package the App
|
||||
## 2. Build and Package the App
|
||||
|
||||
To build the macOS app and package it into `dist/Clawdbot.app`, run:
|
||||
|
||||
@@ -42,7 +34,7 @@ If you don't have an Apple Developer ID certificate, the script will automatical
|
||||
|
||||
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
||||
|
||||
## 4. Install the CLI
|
||||
## 3. Install the CLI
|
||||
|
||||
The macOS app expects a global `clawdbot` CLI install to manage background tasks.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
summary: "PeekabooBridge integration for macOS UI automation"
|
||||
read_when:
|
||||
- Hosting PeekabooBridge in Clawdbot.app
|
||||
- Integrating Peekaboo as a submodule
|
||||
- Integrating Peekaboo via Swift Package Manager
|
||||
- Changing PeekabooBridge protocol/paths
|
||||
---
|
||||
# Peekaboo Bridge (macOS UI automation)
|
||||
|
||||
@@ -54,29 +54,32 @@ The macOS app presents itself as a node. Common commands:
|
||||
|
||||
The node reports a `permissions` map so agents can decide what’s allowed.
|
||||
|
||||
## Node run policy + allowlist
|
||||
## Exec approvals (system.run)
|
||||
|
||||
`system.run` is controlled by the macOS app **Node Run Commands** policy:
|
||||
|
||||
- `Always Ask`: prompt per command (default).
|
||||
- `Always Allow`: run without prompts.
|
||||
- `Never`: disable `system.run` (tool not advertised).
|
||||
|
||||
The policy + allowlist live on the Mac in:
|
||||
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
|
||||
Security + ask + allowlist are stored locally on the Mac in:
|
||||
|
||||
```
|
||||
~/.clawdbot/macos-node.json
|
||||
~/.clawdbot/exec-approvals.json
|
||||
```
|
||||
|
||||
Schema:
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"systemRun": {
|
||||
"policy": "ask",
|
||||
"allowlist": [
|
||||
"[\"/bin/echo\",\"hello\"]"
|
||||
]
|
||||
"version": 1,
|
||||
"defaults": {
|
||||
"security": "deny",
|
||||
"ask": "on-miss"
|
||||
},
|
||||
"agents": {
|
||||
"main": {
|
||||
"security": "allowlist",
|
||||
"ask": "on-miss",
|
||||
"allowlist": [
|
||||
{ "pattern": "/opt/homebrew/bin/rg" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -29,6 +29,7 @@ read_when:
|
||||
- **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.
|
||||
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
|
||||
|
||||
## Key concepts
|
||||
### Host
|
||||
@@ -168,9 +169,9 @@ If UI missing:
|
||||
- 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=<...>)`
|
||||
- `Exec started (node=<id>, id=<runId>)`
|
||||
- `Exec finished (node=<id>, id=<runId>, code=<code>)` + optional output tail
|
||||
- `Exec denied (node=<id>, id=<runId>, <reason>)`
|
||||
|
||||
### Transport
|
||||
Option A (recommended):
|
||||
|
||||
@@ -60,6 +60,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [Remote gateways + nodes](#remote-gateways-nodes)
|
||||
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
||||
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
|
||||
- [Can I run a headless node host without the macOS app?](#can-i-run-a-headless-node-host-without-the-macos-app)
|
||||
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
|
||||
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
|
||||
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
|
||||
@@ -405,7 +406,7 @@ You have three supported patterns:
|
||||
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
|
||||
|
||||
**Option B - use a macOS node (no SSH).**
|
||||
Run the Gateway on Linux, pair a macOS node (menubar app), and set **Node Run Commands** to "Always Ask" or "Always Allow" on the Mac. Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Always Ask", approving "Always Allow" in the prompt adds that command to the allowlist.
|
||||
Run the Gateway on Linux, pair a macOS node (menubar app), and configure **Exec approvals** (Settings → Exec approvals) to "Ask" or "Always Allow". Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Ask", selecting "Always Allow" in the prompt adds that command to the allowlist.
|
||||
|
||||
**Option C - proxy macOS binaries over SSH (advanced).**
|
||||
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
|
||||
@@ -742,6 +743,23 @@ to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
|
||||
|
||||
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
|
||||
|
||||
### Can I run a headless node host without the macOS app?
|
||||
|
||||
Yes. The headless node host is a **command-only** node that exposes `system.run` / `system.which`
|
||||
without any UI. It has no screen/camera/notify support (use the macOS app for those).
|
||||
|
||||
Start it:
|
||||
```bash
|
||||
clawdbot node start --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Pairing is still required (`clawdbot nodes pending` → `clawdbot nodes approve <requestId>`).
|
||||
- Exec approvals still apply via `~/.clawdbot/exec-approvals.json`.
|
||||
- If prompts are enabled but no companion UI is reachable, `askFallback` decides (default: deny).
|
||||
|
||||
Docs: [Node CLI](/cli/node), [Nodes](/nodes), [Exec approvals](/tools/exec-approvals).
|
||||
|
||||
### Is there an API / RPC way to apply config?
|
||||
|
||||
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
|
||||
|
||||
@@ -8,7 +8,7 @@ read_when:
|
||||
|
||||
# Exec approvals
|
||||
|
||||
Exec approvals are the **companion app guardrail** for letting a sandboxed agent run
|
||||
Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run
|
||||
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
|
||||
commands are allowed only when policy + allowlist + (optional) user approval all agree.
|
||||
Exec approvals are **in addition** to tool policy and elevated gating.
|
||||
@@ -20,11 +20,11 @@ resolved by the **ask fallback** (default: deny).
|
||||
|
||||
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)
|
||||
- **node host** → node runner (macOS companion app or headless node host)
|
||||
|
||||
## Settings and storage
|
||||
|
||||
Approvals live in a local JSON file:
|
||||
Approvals live in a local JSON file on the execution host:
|
||||
|
||||
`~/.clawdbot/exec-approvals.json`
|
||||
|
||||
@@ -97,8 +97,18 @@ Each allowlist entry tracks:
|
||||
## Auto-allow skill CLIs
|
||||
|
||||
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
|
||||
are treated as allowlisted (node hosts only). Disable this if you want strict
|
||||
manual allowlists.
|
||||
are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
|
||||
gateway for the skill bin list. Disable this if you want strict manual allowlists.
|
||||
|
||||
## Control UI editing
|
||||
|
||||
Use the **Control UI → Nodes → Exec approvals** card to edit defaults, per‑agent
|
||||
overrides, and allowlists. Pick a scope (Defaults or an agent), tweak the policy,
|
||||
add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata
|
||||
per pattern so you can keep the list tidy.
|
||||
|
||||
Note: the Control UI edits the approvals file on the **Gateway host**. For a
|
||||
headless node host, edit its local `~/.clawdbot/exec-approvals.json` directly.
|
||||
|
||||
## Approval flow
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ 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).
|
||||
- `node` requires a paired node (companion app or headless node host).
|
||||
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
|
||||
|
||||
## Config
|
||||
@@ -41,6 +41,15 @@ Notes:
|
||||
- `tools.exec.ask` (default: `on-miss`)
|
||||
- `tools.exec.node` (default: unset)
|
||||
|
||||
Per-agent node binding (use the agent list index in config):
|
||||
|
||||
```bash
|
||||
clawdbot config get agents.list
|
||||
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
|
||||
```
|
||||
|
||||
Control UI: the Nodes tab includes a small “Exec node binding” panel for the same settings.
|
||||
|
||||
## Session overrides (`/exec`)
|
||||
|
||||
Use `/exec` to set **per-session** defaults for `host`, `security`, `ask`, and `node`.
|
||||
@@ -51,7 +60,7 @@ Example:
|
||||
/exec host=gateway security=allowlist ask=on-miss node=mac-1
|
||||
```
|
||||
|
||||
## Exec approvals (macOS app)
|
||||
## Exec approvals (companion app / node 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.
|
||||
|
||||
@@ -181,6 +181,7 @@ Notes:
|
||||
- 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 is an alias for `host=gateway` + `security=full`.
|
||||
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
||||
- `host=node` can target a macOS companion app or a headless node host (`clawdbot node start`).
|
||||
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
|
||||
|
||||
### `process`
|
||||
|
||||
@@ -187,7 +187,7 @@ Skills can also refresh mid-session when the skills watcher is enabled or when a
|
||||
|
||||
## Remote macOS nodes (Linux gateway)
|
||||
|
||||
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Node Run Commands policy not set to "Never"), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`).
|
||||
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Exec approvals security not set to `deny`), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`).
|
||||
|
||||
This relies on the node reporting its command support and on a bin probe via `system.run`. If the macOS node goes offline later, the skills remain visible; invocations may fail until the node reconnects.
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
||||
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
||||
- Skills: status, enable/disable, install, API key updates (`skills.*`)
|
||||
- Nodes: list + caps (`node.list`)
|
||||
- Exec approvals: edit allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
|
||||
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
|
||||
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
||||
- Config writes include a base-hash guard to prevent clobbering concurrent edits
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import { enqueueSystemEvent, formatAgentEnvelope, type ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
@@ -836,7 +836,7 @@ async function processMessage(
|
||||
const fromLabel = message.isGroup
|
||||
? `group:${peerId}`
|
||||
: message.senderName || `user:${message.senderId}`;
|
||||
const body = formatAgentEnvelope({
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "BlueBubbles",
|
||||
from: fromLabel,
|
||||
timestamp: message.timestamp,
|
||||
@@ -1058,7 +1058,7 @@ async function processReaction(
|
||||
const senderLabel = reaction.senderName || reaction.senderId;
|
||||
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
||||
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`;
|
||||
enqueueSystemEvent(text, {
|
||||
core.system.enqueueSystemEvent(text, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
||||
});
|
||||
|
||||
16
extensions/discord/index.ts
Normal file
16
extensions/discord/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { discordPlugin } from "./src/channel.js";
|
||||
import { setDiscordRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
description: "Discord channel plugin",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setDiscordRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: discordPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
9
extensions/discord/package.json
Normal file
9
extensions/discord/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@clawdbot/discord",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Discord channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,43 @@
|
||||
import {
|
||||
listDiscordAccountIds,
|
||||
type ResolvedDiscordAccount,
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordAccount,
|
||||
} from "../../discord/accounts.js";
|
||||
import {
|
||||
auditDiscordChannelPermissions,
|
||||
collectDiscordAuditChannelIds,
|
||||
} from "../../discord/audit.js";
|
||||
import { probeDiscord } from "../../discord/probe.js";
|
||||
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
|
||||
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
|
||||
import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js";
|
||||
import { shouldLogVerbose } from "../../globals.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import { DiscordConfigSchema } from "../../config/zod-schema.providers-core.js";
|
||||
import { discordMessageActions } from "./actions/discord.js";
|
||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "./config-helpers.js";
|
||||
import { resolveDiscordGroupRequireMention } from "./group-mentions.js";
|
||||
import { formatPairingApproveHint } from "./helpers.js";
|
||||
import { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget } from "./normalize/discord.js";
|
||||
import { discordOnboardingAdapter } from "./onboarding/discord.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "./setup-helpers.js";
|
||||
import { collectDiscordStatusIssues } from "./status-issues/discord.js";
|
||||
import type { ChannelPlugin } from "./types.js";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
collectDiscordAuditChannelIds,
|
||||
collectDiscordStatusIssues,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
discordOnboardingAdapter,
|
||||
DiscordConfigSchema,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
listDiscordAccountIds,
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
} from "./directory-config.js";
|
||||
import {
|
||||
listDiscordDirectoryGroupsLive,
|
||||
listDiscordDirectoryPeersLive,
|
||||
} from "../../discord/directory-live.js";
|
||||
looksLikeDiscordTargetId,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
normalizeDiscordMessagingTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveDiscordAccount,
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordGroupRequireMention,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
type ResolvedDiscordAccount,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getDiscordRuntime } from "./runtime.js";
|
||||
|
||||
const meta = getChatChannelMeta("discord");
|
||||
|
||||
const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions.listActions(ctx),
|
||||
extractToolSend: (ctx) =>
|
||||
getDiscordRuntime().channel.discord.messageActions.extractToolSend(ctx),
|
||||
handleAction: async (ctx) =>
|
||||
await getDiscordRuntime().channel.discord.messageActions.handleAction(ctx),
|
||||
};
|
||||
|
||||
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
id: "discord",
|
||||
meta: {
|
||||
@@ -54,7 +48,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
idLabel: "discordUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""),
|
||||
notifyApproval: async ({ id }) => {
|
||||
await sendMessageDiscord(`user:${id}`, PAIRING_APPROVED_MESSAGE);
|
||||
await getDiscordRuntime().channel.discord.sendMessageDiscord(
|
||||
`user:${id}`,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
@@ -165,8 +162,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
self: async () => null,
|
||||
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
|
||||
listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),
|
||||
listPeersLive: async (params) => listDiscordDirectoryPeersLive(params),
|
||||
listGroupsLive: async (params) => listDiscordDirectoryGroupsLive(params),
|
||||
listPeersLive: async (params) =>
|
||||
getDiscordRuntime().channel.discord.listDirectoryPeersLive(params),
|
||||
listGroupsLive: async (params) =>
|
||||
getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params),
|
||||
},
|
||||
resolver: {
|
||||
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
|
||||
@@ -180,7 +179,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
}));
|
||||
}
|
||||
if (kind === "group") {
|
||||
const resolved = await resolveDiscordChannelAllowlist({ token, entries: inputs });
|
||||
const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({
|
||||
token,
|
||||
entries: inputs,
|
||||
});
|
||||
return resolved.map((entry) => ({
|
||||
input: entry.input,
|
||||
resolved: entry.resolved,
|
||||
@@ -192,7 +194,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
note: entry.note,
|
||||
}));
|
||||
}
|
||||
const resolved = await resolveDiscordUserAllowlist({ token, entries: inputs });
|
||||
const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({
|
||||
token,
|
||||
entries: inputs,
|
||||
});
|
||||
return resolved.map((entry) => ({
|
||||
input: entry.input,
|
||||
resolved: entry.resolved,
|
||||
@@ -274,7 +279,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
textChunkLimit: 2000,
|
||||
pollMaxOptions: 10,
|
||||
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
||||
const send = deps?.sendDiscord ?? sendMessageDiscord;
|
||||
const send =
|
||||
deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
replyTo: replyToId ?? undefined,
|
||||
@@ -283,7 +289,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
return { channel: "discord", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
||||
const send = deps?.sendDiscord ?? sendMessageDiscord;
|
||||
const send =
|
||||
deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
@@ -293,7 +300,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
return { channel: "discord", ...result };
|
||||
},
|
||||
sendPoll: async ({ to, poll, accountId }) =>
|
||||
await sendPollDiscord(to, poll, {
|
||||
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
},
|
||||
@@ -317,7 +324,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
probeDiscord(account.token, timeoutMs, { includeApplication: true }),
|
||||
getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
|
||||
includeApplication: true,
|
||||
}),
|
||||
auditAccount: async ({ account, timeoutMs, cfg }) => {
|
||||
const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
|
||||
cfg,
|
||||
@@ -334,7 +343,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
const audit = await auditDiscordChannelPermissions({
|
||||
const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({
|
||||
token: botToken,
|
||||
accountId: account.accountId,
|
||||
channelIds,
|
||||
@@ -371,7 +380,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
const token = account.token.trim();
|
||||
let discordBotLabel = "";
|
||||
try {
|
||||
const probe = await probeDiscord(token, 2500, {
|
||||
const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, {
|
||||
includeApplication: true,
|
||||
});
|
||||
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
||||
@@ -392,14 +401,12 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (shouldLogVerbose()) {
|
||||
if (getDiscordRuntime().logging.shouldLogVerbose()) {
|
||||
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorDiscordProvider } = await import("../../discord/index.js");
|
||||
return monitorDiscordProvider({
|
||||
return getDiscordRuntime().channel.discord.monitorDiscordProvider({
|
||||
token,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
14
extensions/discord/src/runtime.ts
Normal file
14
extensions/discord/src/runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setDiscordRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getDiscordRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Discord runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
16
extensions/imessage/index.ts
Normal file
16
extensions/imessage/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { imessagePlugin } from "./src/channel.js";
|
||||
import { setIMessageRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "imessage",
|
||||
name: "iMessage",
|
||||
description: "iMessage channel plugin",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setIMessageRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: imessagePlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
9
extensions/imessage/package.json
Normal file
9
extensions/imessage/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@clawdbot/imessage",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot iMessage channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,26 @@
|
||||
import { chunkText } from "../../auto-reply/chunk.js";
|
||||
import {
|
||||
listIMessageAccountIds,
|
||||
type ResolvedIMessageAccount,
|
||||
resolveDefaultIMessageAccountId,
|
||||
resolveIMessageAccount,
|
||||
} from "../../imessage/accounts.js";
|
||||
import { probeIMessage } from "../../imessage/probe.js";
|
||||
import { sendMessageIMessage } from "../../imessage/send.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import { IMessageConfigSchema } from "../../config/zod-schema.providers-core.js";
|
||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "./config-helpers.js";
|
||||
import { resolveIMessageGroupRequireMention } from "./group-mentions.js";
|
||||
import { formatPairingApproveHint } from "./helpers.js";
|
||||
import { resolveChannelMediaMaxBytes } from "./media-limits.js";
|
||||
import { imessageOnboardingAdapter } from "./onboarding/imessage.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
imessageOnboardingAdapter,
|
||||
IMessageConfigSchema,
|
||||
listIMessageAccountIds,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "./setup-helpers.js";
|
||||
import type { ChannelPlugin } from "./types.js";
|
||||
normalizeAccountId,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveChannelMediaMaxBytes,
|
||||
resolveDefaultIMessageAccountId,
|
||||
resolveIMessageAccount,
|
||||
resolveIMessageGroupRequireMention,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelPlugin,
|
||||
type ResolvedIMessageAccount,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getIMessageRuntime } from "./runtime.js";
|
||||
|
||||
const meta = getChatChannelMeta("imessage");
|
||||
|
||||
@@ -32,13 +28,17 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
id: "imessage",
|
||||
meta: {
|
||||
...meta,
|
||||
aliases: ["imsg"],
|
||||
showConfigured: false,
|
||||
},
|
||||
onboarding: imessageOnboardingAdapter,
|
||||
pairing: {
|
||||
idLabel: "imessageSenderId",
|
||||
notifyApproval: async ({ id }) => {
|
||||
await sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE);
|
||||
await getIMessageRuntime().channel.imessage.sendMessageIMessage(
|
||||
id,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
@@ -183,10 +183,10 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkText,
|
||||
chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||
const send = deps?.sendIMessage ?? sendMessageIMessage;
|
||||
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
|
||||
const maxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||
@@ -201,7 +201,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
return { channel: "imessage", ...result };
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
|
||||
const send = deps?.sendIMessage ?? sendMessageIMessage;
|
||||
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
|
||||
const maxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||
@@ -251,7 +251,8 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ timeoutMs }) => probeIMessage(timeoutMs),
|
||||
probeAccount: async ({ timeoutMs }) =>
|
||||
getIMessageRuntime().channel.imessage.probeIMessage(timeoutMs),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
@@ -282,9 +283,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
ctx.log?.info(
|
||||
`[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`,
|
||||
);
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorIMessageProvider } = await import("../../imessage/index.js");
|
||||
return monitorIMessageProvider({
|
||||
return getIMessageRuntime().channel.imessage.monitorIMessageProvider({
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
14
extensions/imessage/src/runtime.ts
Normal file
14
extensions/imessage/src/runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setIMessageRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getIMessageRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("iMessage runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -1,10 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import os from "node:os";
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
import { matrixPlugin } from "./channel.js";
|
||||
import { setMatrixRuntime } from "./runtime.js";
|
||||
|
||||
describe("matrix directory", () => {
|
||||
beforeEach(() => {
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: () => os.tmpdir(),
|
||||
},
|
||||
} as PluginRuntime);
|
||||
});
|
||||
|
||||
it("lists peers and groups from config", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
RoomTopicEventContent,
|
||||
} from "matrix-js-sdk/lib/@types/state_events.js";
|
||||
|
||||
import { loadConfig } from "clawdbot/plugin-sdk";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getActiveMatrixClient } from "./active-client.js";
|
||||
import {
|
||||
@@ -74,12 +74,14 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<M
|
||||
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
|
||||
if (shouldShareClient) {
|
||||
const client = await resolveSharedMatrixClient({
|
||||
cfg: loadConfig() as CoreConfig,
|
||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
return { client, stopOnDone: false };
|
||||
}
|
||||
const auth = await resolveMatrixAuth({ cfg: loadConfig() as CoreConfig });
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
});
|
||||
const client = await createMatrixClient({
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
||||
|
||||
import { loadConfig } from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
|
||||
export type MatrixResolvedConfig = {
|
||||
homeserver: string;
|
||||
@@ -46,7 +46,7 @@ function clean(value?: string): string {
|
||||
}
|
||||
|
||||
export function resolveMatrixConfig(
|
||||
cfg: CoreConfig = loadConfig() as CoreConfig,
|
||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
const matrix = cfg.channels?.matrix ?? {};
|
||||
@@ -75,7 +75,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<MatrixAuth> {
|
||||
const cfg = params?.cfg ?? (loadConfig() as CoreConfig);
|
||||
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
const env = params?.env ?? process.env;
|
||||
const resolved = resolveMatrixConfig(cfg, env);
|
||||
if (!resolved.homeserver) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveStateDir } from "clawdbot/plugin-sdk";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
@@ -16,9 +16,11 @@ const CREDENTIALS_FILENAME = "credentials.json";
|
||||
|
||||
export function resolveMatrixCredentialsDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
stateDir: string = resolveStateDir(env, os.homedir),
|
||||
stateDir?: string,
|
||||
): string {
|
||||
return path.join(stateDir, "credentials", "matrix");
|
||||
const resolvedStateDir =
|
||||
stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
return path.join(resolvedStateDir, "credentials", "matrix");
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
|
||||
@@ -3,7 +3,8 @@ import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { runCommandWithTimeout, type RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
|
||||
const MATRIX_SDK_PACKAGE = "matrix-js-sdk";
|
||||
|
||||
@@ -40,7 +41,7 @@ export async function ensureMatrixSdkInstalled(params: {
|
||||
? ["pnpm", "install"]
|
||||
: ["npm", "install", "--omit=dev", "--silent"];
|
||||
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
|
||||
const result = await runCommandWithTimeout(command, {
|
||||
const result = await getMatrixRuntime().system.runCommandWithTimeout(command, {
|
||||
cwd: root,
|
||||
timeoutMs: 300_000,
|
||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk";
|
||||
import { RoomMemberEvent } from "matrix-js-sdk";
|
||||
|
||||
import { danger, logVerbose, type RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
export function registerMatrixAutoJoin(params: {
|
||||
client: MatrixClient;
|
||||
@@ -10,6 +11,11 @@ export function registerMatrixAutoJoin(params: {
|
||||
runtime: RuntimeEnv;
|
||||
}) {
|
||||
const { client, cfg, runtime } = params;
|
||||
const core = getMatrixRuntime();
|
||||
const logVerbose = (message: string) => {
|
||||
if (!core.logging.shouldLogVerbose()) return;
|
||||
runtime.log?.(message);
|
||||
};
|
||||
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
|
||||
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
|
||||
|
||||
@@ -36,7 +42,7 @@ export function registerMatrixAutoJoin(params: {
|
||||
await client.joinRoom(roomId);
|
||||
logVerbose(`matrix: joined room ${roomId}`);
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`matrix: failed to join room ${roomId}: ${String(err)}`));
|
||||
runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,34 +3,9 @@ import { EventType, RelationType, RoomEvent } from "matrix-js-sdk";
|
||||
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
||||
|
||||
import {
|
||||
buildMentionRegexes,
|
||||
chunkMarkdownText,
|
||||
createReplyDispatcherWithTyping,
|
||||
danger,
|
||||
dispatchReplyFromConfig,
|
||||
enqueueSystemEvent,
|
||||
finalizeInboundContext,
|
||||
formatAgentEnvelope,
|
||||
formatAllowlistMatchMeta,
|
||||
getChildLogger,
|
||||
hasControlCommand,
|
||||
loadConfig,
|
||||
logVerbose,
|
||||
mergeAllowlist,
|
||||
matchesMentionPatterns,
|
||||
readChannelAllowFromStore,
|
||||
recordSessionMetaFromInbound,
|
||||
resolveAgentRoute,
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
resolveStorePath,
|
||||
resolveTextChunkLimit,
|
||||
shouldHandleTextCommands,
|
||||
shouldLogVerbose,
|
||||
summarizeMapping,
|
||||
updateLastRoute,
|
||||
upsertChannelPairingRequest,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
@@ -61,6 +36,7 @@ import { deliverMatrixReplies } from "./replies.js";
|
||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
||||
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
||||
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
export type MonitorMatrixOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
@@ -76,7 +52,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
if (isBunRuntime()) {
|
||||
throw new Error("Matrix provider requires Node (bun runtime not supported)");
|
||||
}
|
||||
let cfg = loadConfig() as CoreConfig;
|
||||
const core = getMatrixRuntime();
|
||||
let cfg = core.config.loadConfig() as CoreConfig;
|
||||
if (cfg.channels?.matrix?.enabled === false) return;
|
||||
|
||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||
@@ -207,8 +184,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
setActiveMatrixClient(client);
|
||||
|
||||
const mentionRegexes = buildMentionRegexes(cfg);
|
||||
const logger = getChildLogger({ module: "matrix-auto-reply" });
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
|
||||
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
|
||||
const logVerboseMessage = (message: string) => {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
logger.debug(message);
|
||||
}
|
||||
};
|
||||
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
@@ -219,8 +201,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
const dmEnabled = dmConfig?.enabled ?? true;
|
||||
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
|
||||
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
|
||||
const allowFrom = dmConfig?.allowFrom ?? [];
|
||||
const textLimit = resolveTextChunkLimit(cfg, "matrix");
|
||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||
const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
||||
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
||||
const startupMs = Date.now();
|
||||
@@ -306,22 +287,22 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
}`;
|
||||
|
||||
if (roomConfigInfo.config && !roomConfigInfo.allowed) {
|
||||
logVerbose(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
||||
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (!roomConfigInfo.allowlistConfigured) {
|
||||
logVerbose(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
||||
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
||||
return;
|
||||
}
|
||||
if (!roomConfigInfo.config) {
|
||||
logVerbose(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
||||
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const senderName = room.getMember(senderId)?.name ?? senderId;
|
||||
const storeAllowFrom = await readChannelAllowFromStore("matrix").catch(() => []);
|
||||
const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
|
||||
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
|
||||
|
||||
if (isDirectMessage) {
|
||||
@@ -335,13 +316,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
||||
if (!allowMatch.allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await upsertChannelPairingRequest({
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "matrix",
|
||||
id: senderId,
|
||||
meta: { name: senderName },
|
||||
});
|
||||
if (created) {
|
||||
logVerbose(
|
||||
logVerboseMessage(
|
||||
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
|
||||
);
|
||||
try {
|
||||
@@ -358,12 +339,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
{ client },
|
||||
);
|
||||
} catch (err) {
|
||||
logVerbose(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dmPolicy !== "pairing") {
|
||||
logVerbose(
|
||||
logVerboseMessage(
|
||||
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
||||
);
|
||||
}
|
||||
@@ -379,7 +360,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
userName: senderName,
|
||||
});
|
||||
if (!userMatch.allowed) {
|
||||
logVerbose(
|
||||
logVerboseMessage(
|
||||
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
||||
userMatch,
|
||||
)})`,
|
||||
@@ -388,7 +369,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
}
|
||||
}
|
||||
if (isRoom) {
|
||||
logVerbose(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
||||
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
||||
}
|
||||
|
||||
const rawBody = content.body.trim();
|
||||
@@ -416,7 +397,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
maxBytes: mediaMaxBytes,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`matrix: media download failed: ${String(err)}`);
|
||||
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,7 +410,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
text: bodyText,
|
||||
mentionRegexes,
|
||||
});
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "matrix",
|
||||
});
|
||||
@@ -439,14 +420,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
userId: senderId,
|
||||
userName: senderName,
|
||||
});
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
],
|
||||
});
|
||||
if (isRoom && allowTextCommands && hasControlCommand(bodyText, cfg) && !commandAuthorized) {
|
||||
logVerbose(`matrix: drop control command from unauthorized sender ${senderId}`);
|
||||
if (
|
||||
isRoom &&
|
||||
allowTextCommands &&
|
||||
core.channel.text.hasControlCommand(bodyText, cfg) &&
|
||||
!commandAuthorized
|
||||
) {
|
||||
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
|
||||
return;
|
||||
}
|
||||
const shouldRequireMention = isRoom
|
||||
@@ -465,7 +451,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
!wasMentioned &&
|
||||
!hasExplicitMention &&
|
||||
commandAuthorized &&
|
||||
hasControlCommand(bodyText);
|
||||
core.channel.text.hasControlCommand(bodyText);
|
||||
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
|
||||
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
|
||||
return;
|
||||
@@ -482,14 +468,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
|
||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||
const body = formatAgentEnvelope({
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Matrix",
|
||||
from: envelopeFrom,
|
||||
timestamp: event.getTs() ?? undefined,
|
||||
body: textWithId,
|
||||
});
|
||||
|
||||
const route = resolveAgentRoute({
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
peer: {
|
||||
@@ -499,7 +485,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
|
||||
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: bodyText,
|
||||
CommandBody: bodyText,
|
||||
@@ -531,10 +517,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
OriginatingTo: `room:${roomId}`,
|
||||
});
|
||||
|
||||
const storePath = resolveStorePath(cfg.session?.store, {
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void recordSessionMetaFromInbound({
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
@@ -546,7 +532,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
|
||||
if (isDirectMessage) {
|
||||
await updateLastRoute({
|
||||
await core.channel.session.updateLastRoute({
|
||||
storePath,
|
||||
sessionKey: route.mainSessionKey,
|
||||
channel: "matrix",
|
||||
@@ -556,10 +542,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||
logVerbose(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
||||
}
|
||||
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
||||
|
||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
@@ -577,20 +561,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
};
|
||||
if (shouldAckReaction() && messageId) {
|
||||
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
|
||||
logVerbose(`matrix react failed for room ${roomId}: ${String(err)}`);
|
||||
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
const replyTarget = ctxPayload.To;
|
||||
if (!replyTarget) {
|
||||
runtime.error?.(danger("matrix: missing reply target"));
|
||||
runtime.error?.("matrix: missing reply target");
|
||||
return;
|
||||
}
|
||||
|
||||
let didSendReply = false;
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload) => {
|
||||
await deliverMatrixReplies({
|
||||
replies: [payload],
|
||||
@@ -604,13 +588,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
didSendReply = true;
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(danger(`matrix ${info.kind} reply failed: ${String(err)}`));
|
||||
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
onReplyStart: () => sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
|
||||
onIdle: () => sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
|
||||
});
|
||||
|
||||
const { queuedFinal, counts } = await dispatchReplyFromConfig({
|
||||
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
@@ -622,19 +606,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
markDispatchIdle();
|
||||
if (!queuedFinal) return;
|
||||
didSendReply = true;
|
||||
if (shouldLogVerbose()) {
|
||||
const finalCount = counts.final;
|
||||
logVerbose(`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`);
|
||||
}
|
||||
const finalCount = counts.final;
|
||||
logVerboseMessage(
|
||||
`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||
);
|
||||
if (didSendReply) {
|
||||
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
||||
enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, {
|
||||
core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`matrix handler failed: ${String(err)}`));
|
||||
runtime.error?.(`matrix handler failed: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { saveMediaBuffer } from "clawdbot/plugin-sdk";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
async function fetchMatrixMediaBuffer(params: {
|
||||
client: MatrixClient;
|
||||
@@ -49,7 +49,12 @@ export async function downloadMatrixMedia(params: {
|
||||
});
|
||||
if (!fetched) return null;
|
||||
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
||||
const saved = await saveMediaBuffer(fetched.buffer, headerType, "inbound", params.maxBytes);
|
||||
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
headerType,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
||||
|
||||
import { matchesMentionPatterns } from "clawdbot/plugin-sdk";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
export function resolveMentions(params: {
|
||||
content: RoomMessageEventContent;
|
||||
@@ -17,6 +17,9 @@ export function resolveMentions(params: {
|
||||
const wasMentioned =
|
||||
Boolean(mentions?.room) ||
|
||||
(params.userId ? mentionedUsers.has(params.userId) : false) ||
|
||||
matchesMentionPatterns(params.text ?? "", params.mentionRegexes);
|
||||
getMatrixRuntime().channel.mentions.matchesMentionPatterns(
|
||||
params.text ?? "",
|
||||
params.mentionRegexes,
|
||||
);
|
||||
return { wasMentioned, hasExplicitMention: Boolean(mentions) };
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import {
|
||||
chunkMarkdownText,
|
||||
danger,
|
||||
logVerbose,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import { sendMessageMatrix } from "../send.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
export async function deliverMatrixReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
@@ -18,6 +13,12 @@ export async function deliverMatrixReplies(params: {
|
||||
replyToMode: "off" | "first" | "all";
|
||||
threadId?: string;
|
||||
}): Promise<void> {
|
||||
const core = getMatrixRuntime();
|
||||
const logVerbose = (message: string) => {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
params.runtime.log?.(message);
|
||||
}
|
||||
};
|
||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||
let hasReplied = false;
|
||||
for (const reply of params.replies) {
|
||||
@@ -27,7 +28,7 @@ export async function deliverMatrixReplies(params: {
|
||||
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
|
||||
continue;
|
||||
}
|
||||
params.runtime.error?.(danger("matrix reply missing text/media"));
|
||||
params.runtime.error?.("matrix reply missing text/media");
|
||||
continue;
|
||||
}
|
||||
const replyToIdRaw = reply.replyToId?.trim();
|
||||
@@ -42,7 +43,7 @@ export async function deliverMatrixReplies(params: {
|
||||
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkMarkdownText(reply.text ?? "", chunkLimit)) {
|
||||
for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) continue;
|
||||
await sendMessageMatrix(params.roomId, trimmed, {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import { setMatrixRuntime } from "../runtime.js";
|
||||
|
||||
vi.mock("matrix-js-sdk", () => ({
|
||||
EventType: {
|
||||
Direct: "m.direct",
|
||||
@@ -18,21 +21,33 @@ vi.mock("matrix-js-sdk", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("clawdbot/plugin-sdk", () => ({
|
||||
loadConfig: () => ({}),
|
||||
resolveTextChunkLimit: () => 4000,
|
||||
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
||||
loadWebMedia: vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("media"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
}),
|
||||
mediaKindFromMime: () => "image",
|
||||
isVoiceCompatibleAudio: () => false,
|
||||
getImageMetadata: vi.fn().mockResolvedValue(null),
|
||||
resizeToJpeg: vi.fn(),
|
||||
}));
|
||||
const loadWebMediaMock = vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("media"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
});
|
||||
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
|
||||
const resizeToJpegMock = vi.fn();
|
||||
|
||||
const runtimeStub = {
|
||||
config: {
|
||||
loadConfig: () => ({}),
|
||||
},
|
||||
media: {
|
||||
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
||||
mediaKindFromMime: () => "image",
|
||||
isVoiceCompatibleAudio: () => false,
|
||||
getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args),
|
||||
resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args),
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
resolveTextChunkLimit: () => 4000,
|
||||
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
||||
|
||||
@@ -50,11 +65,13 @@ const makeClient = () => {
|
||||
|
||||
describe("sendMessageMatrix media", () => {
|
||||
beforeAll(async () => {
|
||||
setMatrixRuntime(runtimeStub);
|
||||
({ sendMessageMatrix } = await import("./send.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setMatrixRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("uploads media with url payloads", async () => {
|
||||
|
||||
@@ -5,17 +5,8 @@ import type {
|
||||
ReactionEventContent,
|
||||
} from "matrix-js-sdk/lib/@types/events.js";
|
||||
|
||||
import {
|
||||
chunkMarkdownText,
|
||||
getImageMetadata,
|
||||
isVoiceCompatibleAudio,
|
||||
loadConfig,
|
||||
loadWebMedia,
|
||||
mediaKindFromMime,
|
||||
type PollInput,
|
||||
resolveTextChunkLimit,
|
||||
resizeToJpeg,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { PollInput } from "clawdbot/plugin-sdk";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import { getActiveMatrixClient } from "./active-client.js";
|
||||
import {
|
||||
createMatrixClient,
|
||||
@@ -29,6 +20,7 @@ import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
|
||||
const MATRIX_TEXT_LIMIT = 4000;
|
||||
const getCore = () => getMatrixRuntime();
|
||||
|
||||
type MatrixDirectAccountData = AccountDataEvents[EventType.Direct];
|
||||
|
||||
@@ -65,7 +57,7 @@ function ensureNodeRuntime() {
|
||||
}
|
||||
|
||||
function resolveMediaMaxBytes(): number | undefined {
|
||||
const cfg = loadConfig() as CoreConfig;
|
||||
const cfg = getCore().config.loadConfig() as CoreConfig;
|
||||
if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
|
||||
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
|
||||
}
|
||||
@@ -224,7 +216,7 @@ function resolveMatrixMsgType(
|
||||
contentType?: string,
|
||||
fileName?: string,
|
||||
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
|
||||
const kind = mediaKindFromMime(contentType ?? "");
|
||||
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return MsgType.Image;
|
||||
@@ -243,7 +235,7 @@ function resolveMatrixVoiceDecision(opts: {
|
||||
fileName?: string;
|
||||
}): { useVoice: boolean } {
|
||||
if (!opts.wantsVoice) return { useVoice: false };
|
||||
if (isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
|
||||
if (getCore().media.isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
|
||||
return { useVoice: true };
|
||||
}
|
||||
return { useVoice: false };
|
||||
@@ -256,19 +248,19 @@ async function prepareImageInfo(params: {
|
||||
buffer: Buffer;
|
||||
client: MatrixClient;
|
||||
}): Promise<MatrixImageInfo | undefined> {
|
||||
const meta = await getImageMetadata(params.buffer).catch(() => null);
|
||||
const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null);
|
||||
if (!meta) return undefined;
|
||||
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
|
||||
const maxDim = Math.max(meta.width, meta.height);
|
||||
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
||||
try {
|
||||
const thumbBuffer = await resizeToJpeg({
|
||||
const thumbBuffer = await getCore().media.resizeToJpeg({
|
||||
buffer: params.buffer,
|
||||
maxSide: THUMBNAIL_MAX_SIDE,
|
||||
quality: THUMBNAIL_QUALITY,
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
const thumbMeta = await getImageMetadata(thumbBuffer).catch(() => null);
|
||||
const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null);
|
||||
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
|
||||
type: "image/jpeg",
|
||||
name: "thumbnail.jpg",
|
||||
@@ -352,10 +344,10 @@ export async function sendMessageMatrix(
|
||||
});
|
||||
try {
|
||||
const roomId = await resolveMatrixRoomId(client, to);
|
||||
const cfg = loadConfig();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "matrix");
|
||||
const cfg = getCore().config.loadConfig();
|
||||
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
||||
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
|
||||
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
|
||||
const threadId = normalizeThreadId(opts.threadId);
|
||||
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
|
||||
const sendContent = (content: RoomMessageEventContent) =>
|
||||
@@ -364,7 +356,7 @@ export async function sendMessageMatrix(
|
||||
let lastMessageId = "";
|
||||
if (opts.mediaUrl) {
|
||||
const maxBytes = resolveMediaMaxBytes();
|
||||
const media = await loadWebMedia(opts.mediaUrl, maxBytes);
|
||||
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
|
||||
const contentUri = await uploadFile(client, media.buffer, {
|
||||
contentType: media.contentType,
|
||||
filename: media.fileName,
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
createMemoryGetTool,
|
||||
createMemorySearchTool,
|
||||
registerMemoryCli,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
const memoryCorePlugin = {
|
||||
id: "memory-core",
|
||||
name: "Memory (Core)",
|
||||
@@ -14,11 +8,11 @@ const memoryCorePlugin = {
|
||||
register(api: ClawdbotPluginApi) {
|
||||
api.registerTool(
|
||||
(ctx) => {
|
||||
const memorySearchTool = createMemorySearchTool({
|
||||
const memorySearchTool = api.runtime.tools.createMemorySearchTool({
|
||||
config: ctx.config,
|
||||
agentSessionKey: ctx.sessionKey,
|
||||
});
|
||||
const memoryGetTool = createMemoryGetTool({
|
||||
const memoryGetTool = api.runtime.tools.createMemoryGetTool({
|
||||
config: ctx.config,
|
||||
agentSessionKey: ctx.sessionKey,
|
||||
});
|
||||
@@ -30,7 +24,7 @@ const memoryCorePlugin = {
|
||||
|
||||
api.registerCli(
|
||||
({ program }) => {
|
||||
registerMemoryCli(program);
|
||||
api.runtime.tools.registerMemoryCli(program);
|
||||
},
|
||||
{ commands: ["memory"] },
|
||||
);
|
||||
|
||||
102
extensions/memory/config.ts
Normal file
102
extensions/memory/config.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export type MemoryConfig = {
|
||||
embedding: {
|
||||
provider: "openai";
|
||||
model?: string;
|
||||
apiKey: string;
|
||||
};
|
||||
dbPath?: string;
|
||||
autoCapture?: boolean;
|
||||
autoRecall?: boolean;
|
||||
};
|
||||
|
||||
export const MEMORY_CATEGORIES = ["preference", "fact", "decision", "entity", "other"] as const;
|
||||
export type MemoryCategory = (typeof MEMORY_CATEGORIES)[number];
|
||||
|
||||
const DEFAULT_MODEL = "text-embedding-3-small";
|
||||
const DEFAULT_DB_PATH = join(homedir(), ".clawdbot", "memory", "lancedb");
|
||||
|
||||
const EMBEDDING_DIMENSIONS: Record<string, number> = {
|
||||
"text-embedding-3-small": 1536,
|
||||
"text-embedding-3-large": 3072,
|
||||
};
|
||||
|
||||
export function vectorDimsForModel(model: string): number {
|
||||
const dims = EMBEDDING_DIMENSIONS[model];
|
||||
if (!dims) {
|
||||
throw new Error(`Unsupported embedding model: ${model}`);
|
||||
}
|
||||
return dims;
|
||||
}
|
||||
|
||||
function resolveEnvVars(value: string): string {
|
||||
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
|
||||
const envValue = process.env[envVar];
|
||||
if (!envValue) {
|
||||
throw new Error(`Environment variable ${envVar} is not set`);
|
||||
}
|
||||
return envValue;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEmbeddingModel(embedding: Record<string, unknown>): string {
|
||||
const model = typeof embedding.model === "string" ? embedding.model : DEFAULT_MODEL;
|
||||
vectorDimsForModel(model);
|
||||
return model;
|
||||
}
|
||||
|
||||
export const memoryConfigSchema = {
|
||||
parse(value: unknown): MemoryConfig {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("memory config required");
|
||||
}
|
||||
const cfg = value as Record<string, unknown>;
|
||||
|
||||
const embedding = cfg.embedding as Record<string, unknown> | undefined;
|
||||
if (!embedding || typeof embedding.apiKey !== "string") {
|
||||
throw new Error("embedding.apiKey is required");
|
||||
}
|
||||
|
||||
const model = resolveEmbeddingModel(embedding);
|
||||
|
||||
return {
|
||||
embedding: {
|
||||
provider: "openai",
|
||||
model,
|
||||
apiKey: resolveEnvVars(embedding.apiKey),
|
||||
},
|
||||
dbPath: typeof cfg.dbPath === "string" ? cfg.dbPath : DEFAULT_DB_PATH,
|
||||
autoCapture: cfg.autoCapture !== false,
|
||||
autoRecall: cfg.autoRecall !== false,
|
||||
};
|
||||
},
|
||||
uiHints: {
|
||||
"embedding.apiKey": {
|
||||
label: "OpenAI API Key",
|
||||
sensitive: true,
|
||||
placeholder: "sk-proj-...",
|
||||
help: "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})",
|
||||
},
|
||||
"embedding.model": {
|
||||
label: "Embedding Model",
|
||||
placeholder: DEFAULT_MODEL,
|
||||
help: "OpenAI embedding model to use",
|
||||
},
|
||||
dbPath: {
|
||||
label: "Database Path",
|
||||
placeholder: "~/.clawdbot/memory/lancedb",
|
||||
advanced: true,
|
||||
},
|
||||
autoCapture: {
|
||||
label: "Auto-Capture",
|
||||
help: "Automatically capture important information from conversations",
|
||||
},
|
||||
autoRecall: {
|
||||
label: "Auto-Recall",
|
||||
help: "Automatically inject relevant memories into context",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
@@ -10,28 +10,20 @@ import { Type } from "@sinclair/typebox";
|
||||
import * as lancedb from "@lancedb/lancedb";
|
||||
import OpenAI from "openai";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { stringEnum } from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
MEMORY_CATEGORIES,
|
||||
type MemoryCategory,
|
||||
memoryConfigSchema,
|
||||
vectorDimsForModel,
|
||||
} from "./config.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type MemoryConfig = {
|
||||
embedding: {
|
||||
provider: "openai";
|
||||
model?: string;
|
||||
apiKey: string;
|
||||
};
|
||||
dbPath?: string;
|
||||
autoCapture?: boolean;
|
||||
autoRecall?: boolean;
|
||||
};
|
||||
|
||||
const MEMORY_CATEGORIES = ["preference", "fact", "decision", "entity", "other"] as const;
|
||||
type MemoryCategory = (typeof MEMORY_CATEGORIES)[number];
|
||||
|
||||
type MemoryEntry = {
|
||||
id: string;
|
||||
text: string;
|
||||
@@ -46,106 +38,12 @@ type MemorySearchResult = {
|
||||
score: number;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Config Schema
|
||||
// ============================================================================
|
||||
|
||||
const memoryConfigSchema = {
|
||||
parse(value: unknown): MemoryConfig {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("memory config required");
|
||||
}
|
||||
const cfg = value as Record<string, unknown>;
|
||||
|
||||
// Embedding config is required
|
||||
const embedding = cfg.embedding as Record<string, unknown> | undefined;
|
||||
if (!embedding || typeof embedding.apiKey !== "string") {
|
||||
throw new Error("embedding.apiKey is required");
|
||||
}
|
||||
|
||||
const model =
|
||||
typeof embedding.model === "string" ? embedding.model : "text-embedding-3-small";
|
||||
ensureSupportedEmbeddingModel(model);
|
||||
|
||||
return {
|
||||
embedding: {
|
||||
provider: "openai",
|
||||
model,
|
||||
apiKey: resolveEnvVars(embedding.apiKey),
|
||||
},
|
||||
dbPath:
|
||||
typeof cfg.dbPath === "string"
|
||||
? cfg.dbPath
|
||||
: join(homedir(), ".clawdbot", "memory", "lancedb"),
|
||||
autoCapture: cfg.autoCapture !== false,
|
||||
autoRecall: cfg.autoRecall !== false,
|
||||
};
|
||||
},
|
||||
uiHints: {
|
||||
"embedding.apiKey": {
|
||||
label: "OpenAI API Key",
|
||||
sensitive: true,
|
||||
placeholder: "sk-proj-...",
|
||||
help: "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})",
|
||||
},
|
||||
"embedding.model": {
|
||||
label: "Embedding Model",
|
||||
placeholder: "text-embedding-3-small",
|
||||
help: "OpenAI embedding model to use",
|
||||
},
|
||||
dbPath: {
|
||||
label: "Database Path",
|
||||
placeholder: "~/.clawdbot/memory/lancedb",
|
||||
advanced: true,
|
||||
},
|
||||
autoCapture: {
|
||||
label: "Auto-Capture",
|
||||
help: "Automatically capture important information from conversations",
|
||||
},
|
||||
autoRecall: {
|
||||
label: "Auto-Recall",
|
||||
help: "Automatically inject relevant memories into context",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const EMBEDDING_DIMENSIONS: Record<string, number> = {
|
||||
"text-embedding-3-small": 1536,
|
||||
"text-embedding-3-large": 3072,
|
||||
};
|
||||
|
||||
function ensureSupportedEmbeddingModel(model: string): void {
|
||||
if (!EMBEDDING_DIMENSIONS[model]) {
|
||||
throw new Error(`Unsupported embedding model: ${model}`);
|
||||
}
|
||||
}
|
||||
|
||||
function stringEnum<T extends readonly string[]>(
|
||||
values: T,
|
||||
options: { description?: string } = {},
|
||||
) {
|
||||
return Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: [...values],
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEnvVars(value: string): string {
|
||||
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
|
||||
const envValue = process.env[envVar];
|
||||
if (!envValue) {
|
||||
throw new Error(`Environment variable ${envVar} is not set`);
|
||||
}
|
||||
return envValue;
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LanceDB Provider
|
||||
// ============================================================================
|
||||
|
||||
const TABLE_NAME = "memories";
|
||||
|
||||
class MemoryDB {
|
||||
private db: lancedb.Connection | null = null;
|
||||
private table: lancedb.Table | null = null;
|
||||
@@ -324,12 +222,12 @@ const memoryPlugin = {
|
||||
|
||||
register(api: ClawdbotPluginApi) {
|
||||
const cfg = memoryConfigSchema.parse(api.pluginConfig);
|
||||
const resolvedDbPath = api.resolvePath(cfg.dbPath!);
|
||||
const vectorDim = EMBEDDING_DIMENSIONS[cfg.embedding.model ?? "text-embedding-3-small"];
|
||||
const db = new MemoryDB(resolvedDbPath, vectorDim);
|
||||
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!);
|
||||
const resolvedDbPath = api.resolvePath(cfg.dbPath!);
|
||||
const vectorDim = vectorDimsForModel(cfg.embedding.model ?? "text-embedding-3-small");
|
||||
const db = new MemoryDB(resolvedDbPath, vectorDim);
|
||||
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!);
|
||||
|
||||
api.logger.info(`memory: plugin registered (db: ${resolvedDbPath}, lazy init)`);
|
||||
api.logger.info(`memory: plugin registered (db: ${resolvedDbPath}, lazy init)`);
|
||||
|
||||
// ========================================================================
|
||||
// Tools
|
||||
@@ -479,9 +377,9 @@ const memoryPlugin = {
|
||||
};
|
||||
}
|
||||
|
||||
const list = results
|
||||
.map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`)
|
||||
.join("\n");
|
||||
const list = results
|
||||
.map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`)
|
||||
.join("\n");
|
||||
|
||||
// Strip vector data for serialization
|
||||
const sanitizedCandidates = results.map((r) => ({
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { msteamsPlugin } from "./src/channel.js";
|
||||
import { setMSTeamsRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "msteams",
|
||||
name: "Microsoft Teams",
|
||||
description: "Microsoft Teams channel plugin (Bot Framework)",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setMSTeamsRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: msteamsPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
const detectMimeMock = vi.fn(async () => "image/png");
|
||||
const saveMediaBufferMock = vi.fn(async () => ({
|
||||
path: "/tmp/saved.png",
|
||||
contentType: "image/png",
|
||||
}));
|
||||
|
||||
vi.mock("clawdbot/plugin-sdk", () => ({
|
||||
detectMime: (...args: unknown[]) => detectMimeMock(...args),
|
||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
|
||||
}));
|
||||
const runtimeStub = {
|
||||
media: {
|
||||
detectMime: (...args: unknown[]) => detectMimeMock(...args),
|
||||
},
|
||||
channel: {
|
||||
media: {
|
||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
describe("msteams attachments", () => {
|
||||
const load = async () => {
|
||||
@@ -19,6 +28,7 @@ describe("msteams attachments", () => {
|
||||
beforeEach(() => {
|
||||
detectMimeMock.mockClear();
|
||||
saveMediaBufferMock.mockClear();
|
||||
setMSTeamsRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
describe("buildMSTeamsAttachmentPlaceholder", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import {
|
||||
extractInlineImageCandidates,
|
||||
inferPlaceholder,
|
||||
@@ -141,7 +141,7 @@ export async function downloadMSTeamsImageAttachments(params: {
|
||||
if (inline.kind !== "data") continue;
|
||||
if (inline.data.byteLength > params.maxBytes) continue;
|
||||
try {
|
||||
const saved = await saveMediaBuffer(
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
inline.data,
|
||||
inline.contentType,
|
||||
"inbound",
|
||||
@@ -167,12 +167,12 @@ export async function downloadMSTeamsImageAttachments(params: {
|
||||
if (!res.ok) continue;
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
if (buffer.byteLength > params.maxBytes) continue;
|
||||
const mime = await detectMime({
|
||||
const mime = await getMSTeamsRuntime().media.detectMime({
|
||||
buffer,
|
||||
headerMime: res.headers.get("content-type"),
|
||||
filePath: candidate.fileHint ?? candidate.url,
|
||||
});
|
||||
const saved = await saveMediaBuffer(
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
buffer,
|
||||
mime ?? candidate.contentTypeHint,
|
||||
"inbound",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { downloadMSTeamsImageAttachments } from "./download.js";
|
||||
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
||||
import type {
|
||||
@@ -154,13 +154,13 @@ async function downloadGraphHostedImages(params: {
|
||||
continue;
|
||||
}
|
||||
if (buffer.byteLength > params.maxBytes) continue;
|
||||
const mime = await detectMime({
|
||||
const mime = await getMSTeamsRuntime().media.detectMime({
|
||||
buffer,
|
||||
headerMime: item.contentType ?? undefined,
|
||||
});
|
||||
if (mime && !mime.startsWith("image/")) continue;
|
||||
try {
|
||||
const saved = await saveMediaBuffer(
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
buffer,
|
||||
mime ?? item.contentType ?? undefined,
|
||||
"inbound",
|
||||
|
||||
@@ -2,12 +2,29 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
const runtimeStub = {
|
||||
state: {
|
||||
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
|
||||
const override = env.CLAWDBOT_STATE_DIR?.trim();
|
||||
if (override) return override;
|
||||
const resolvedHome = homedir ? homedir() : os.homedir();
|
||||
return path.join(resolvedHome, ".clawdbot");
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
describe("msteams conversation store (fs)", () => {
|
||||
beforeEach(() => {
|
||||
setMSTeamsRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("filters and prunes expired entries (but keeps legacy ones)", async () => {
|
||||
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-msteams-store-"));
|
||||
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { SILENT_REPLY_TOKEN } from "clawdbot/plugin-sdk";
|
||||
import { SILENT_REPLY_TOKEN, type PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import {
|
||||
type MSTeamsAdapter,
|
||||
renderReplyPayloadsToMessages,
|
||||
sendMSTeamsMessages,
|
||||
} from "./messenger.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
const runtimeStub = {
|
||||
channel: {
|
||||
text: {
|
||||
chunkMarkdownText: (text: string, limit: number) => {
|
||||
if (!text) return [];
|
||||
if (limit <= 0 || text.length <= limit) return [text];
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < text.length; index += limit) {
|
||||
chunks.push(text.slice(index, index + limit));
|
||||
}
|
||||
return chunks;
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
describe("msteams messenger", () => {
|
||||
beforeEach(() => {
|
||||
setMSTeamsRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
describe("renderReplyPayloadsToMessages", () => {
|
||||
it("filters silent replies", () => {
|
||||
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
chunkMarkdownText,
|
||||
isSilentReplyText,
|
||||
type MSTeamsReplyStyle,
|
||||
type ReplyPayload,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import { classifyMSTeamsSendError } from "./errors.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
type SendContext = {
|
||||
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
||||
@@ -108,7 +108,7 @@ function pushTextMessages(
|
||||
) {
|
||||
if (!text) return;
|
||||
if (opts.chunkText) {
|
||||
for (const chunk of chunkMarkdownText(text, opts.chunkLimit)) {
|
||||
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
||||
out.push(trimmed);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import { danger } from "clawdbot/plugin-sdk";
|
||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
|
||||
@@ -42,7 +41,7 @@ export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
|
||||
try {
|
||||
await handleTeamsMessage(context as MSTeamsTurnContext);
|
||||
} catch (err) {
|
||||
deps.runtime.error?.(danger(`msteams handler failed: ${String(err)}`));
|
||||
deps.runtime.error?.(`msteams handler failed: ${String(err)}`);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntries,
|
||||
createInboundDebouncer,
|
||||
danger,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
readChannelAllowFromStore,
|
||||
recordSessionMetaFromInbound,
|
||||
recordPendingHistoryEntry,
|
||||
resolveAgentRoute,
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
resolveInboundDebounceMs,
|
||||
resolveMentionGating,
|
||||
resolveStorePath,
|
||||
dispatchReplyFromConfig,
|
||||
finalizeInboundContext,
|
||||
formatAgentEnvelope,
|
||||
formatAllowlistMatchMeta,
|
||||
hasControlCommand,
|
||||
logVerbose,
|
||||
shouldLogVerbose,
|
||||
upsertChannelPairingRequest,
|
||||
type HistoryEntry,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
@@ -50,6 +35,7 @@ import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
|
||||
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
|
||||
import type { MSTeamsTurnContext } from "../sdk-types.js";
|
||||
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
|
||||
export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
const {
|
||||
@@ -64,6 +50,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
pollStore,
|
||||
log,
|
||||
} = deps;
|
||||
const core = getMSTeamsRuntime();
|
||||
const logVerboseMessage = (message: string) => {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
log.debug(message);
|
||||
}
|
||||
};
|
||||
const msteamsCfg = cfg.channels?.msteams;
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
@@ -72,7 +64,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
const conversationHistories = new Map<string, HistoryEntry[]>();
|
||||
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "msteams" });
|
||||
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
||||
cfg,
|
||||
channel: "msteams",
|
||||
});
|
||||
|
||||
type MSTeamsDebounceEntry = {
|
||||
context: MSTeamsTurnContext;
|
||||
@@ -126,7 +121,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
const senderName = from.name ?? from.id;
|
||||
const senderId = from.aadObjectId ?? from.id;
|
||||
const storedAllowFrom = await readChannelAllowFromStore("msteams").catch(() => []);
|
||||
const storedAllowFrom = await core.channel.pairing
|
||||
.readAllowFromStore("msteams")
|
||||
.catch(() => []);
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
|
||||
// Check DM policy for direct messages.
|
||||
@@ -151,7 +148,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
if (!allowMatch.allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const request = await upsertChannelPairingRequest({
|
||||
const request = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "msteams",
|
||||
id: senderId,
|
||||
meta: { name: senderName },
|
||||
@@ -254,15 +251,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
senderId,
|
||||
senderName,
|
||||
});
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
||||
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
||||
],
|
||||
});
|
||||
if (hasControlCommand(text, cfg) && !commandAuthorized) {
|
||||
logVerbose(`msteams: drop control command from unauthorized sender ${senderId}`);
|
||||
if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) {
|
||||
logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -329,7 +326,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
: `msteams:group:${conversationId}`;
|
||||
const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`;
|
||||
|
||||
const route = resolveAgentRoute({
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "msteams",
|
||||
peer: {
|
||||
@@ -343,7 +340,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
? `Teams DM from ${senderName}`
|
||||
: `Teams message in ${conversationType} from ${senderName}`;
|
||||
|
||||
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
|
||||
});
|
||||
@@ -409,7 +406,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||
const body = formatAgentEnvelope({
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Teams",
|
||||
from: envelopeFrom,
|
||||
timestamp,
|
||||
@@ -425,7 +422,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
formatAgentEnvelope({
|
||||
core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Teams",
|
||||
from: conversationType,
|
||||
timestamp: entry.timestamp,
|
||||
@@ -434,7 +431,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
});
|
||||
}
|
||||
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
@@ -458,20 +455,18 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
...mediaPayload,
|
||||
});
|
||||
|
||||
const storePath = resolveStorePath(cfg.session?.store, {
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void recordSessionMetaFromInbound({
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
}).catch((err) => {
|
||||
logVerbose(`msteams: failed updating session meta: ${String(err)}`);
|
||||
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
|
||||
});
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
||||
}
|
||||
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
|
||||
cfg,
|
||||
@@ -493,7 +488,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
||||
try {
|
||||
const { queuedFinal, counts } = await dispatchReplyFromConfig({
|
||||
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
@@ -513,18 +508,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (shouldLogVerbose()) {
|
||||
const finalCount = counts.final;
|
||||
logVerbose(
|
||||
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
||||
);
|
||||
}
|
||||
const finalCount = counts.final;
|
||||
logVerboseMessage(
|
||||
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
||||
);
|
||||
if (isRoomish && historyKey && historyLimit > 0) {
|
||||
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("dispatch failed", { error: String(err) });
|
||||
runtime.error?.(danger(`msteams dispatch failed: ${String(err)}`));
|
||||
runtime.error?.(`msteams dispatch failed: ${String(err)}`);
|
||||
try {
|
||||
await context.sendActivity(
|
||||
`⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
@@ -535,7 +528,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
}
|
||||
};
|
||||
|
||||
const inboundDebouncer = createInboundDebouncer<MSTeamsDebounceEntry>({
|
||||
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<MSTeamsDebounceEntry>({
|
||||
debounceMs: inboundDebounceMs,
|
||||
buildKey: (entry) => {
|
||||
const conversationId = normalizeMSTeamsConversationId(
|
||||
@@ -549,7 +542,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
shouldDebounce: (entry) => {
|
||||
if (!entry.text.trim()) return false;
|
||||
if (entry.attachments.length > 0) return false;
|
||||
return !hasControlCommand(entry.text, cfg);
|
||||
return !core.channel.text.hasControlCommand(entry.text, cfg);
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
const last = entries.at(-1);
|
||||
@@ -579,7 +572,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
runtime.error?.(danger(`msteams debounce flush failed: ${String(err)}`));
|
||||
runtime.error?.(`msteams debounce flush failed: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
getChildLogger,
|
||||
mergeAllowlist,
|
||||
resolveTextChunkLimit,
|
||||
summarizeMapping,
|
||||
type ClawdbotConfig,
|
||||
type RuntimeEnv,
|
||||
@@ -19,8 +17,7 @@ import {
|
||||
} from "./resolve-allowlist.js";
|
||||
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
const log = getChildLogger({ name: "msteams" });
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
export type MonitorMSTeamsOpts = {
|
||||
cfg: ClawdbotConfig;
|
||||
@@ -38,6 +35,8 @@ export type MonitorMSTeamsResult = {
|
||||
export async function monitorMSTeamsProvider(
|
||||
opts: MonitorMSTeamsOpts,
|
||||
): Promise<MonitorMSTeamsResult> {
|
||||
const core = getMSTeamsRuntime();
|
||||
const log = core.logging.getChildLogger({ name: "msteams" });
|
||||
let cfg = opts.cfg;
|
||||
let msteamsCfg = cfg.channels?.msteams;
|
||||
if (!msteamsCfg?.enabled) {
|
||||
@@ -197,7 +196,7 @@ export async function monitorMSTeamsProvider(
|
||||
};
|
||||
|
||||
const port = msteamsCfg.webhook?.port ?? 3978;
|
||||
const textLimit = resolveTextChunkLimit(cfg, "msteams");
|
||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "msteams");
|
||||
const MB = 1024 * 1024;
|
||||
const agentDefaults = cfg.agents?.defaults;
|
||||
const mediaMaxBytes =
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { chunkMarkdownText, type ChannelOutboundAdapter } from "clawdbot/plugin-sdk";
|
||||
import type { ChannelOutboundAdapter } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { createMSTeamsPollStoreFs } from "./polls.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
|
||||
|
||||
export const msteamsOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkMarkdownText,
|
||||
chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
textChunkLimit: 4000,
|
||||
pollMaxOptions: 12,
|
||||
sendText: async ({ cfg, to, text, deps }) => {
|
||||
|
||||
@@ -2,11 +2,28 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
const runtimeStub = {
|
||||
state: {
|
||||
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
|
||||
const override = env.CLAWDBOT_STATE_DIR?.trim();
|
||||
if (override) return override;
|
||||
const resolvedHome = homedir ? homedir() : os.homedir();
|
||||
return path.join(resolvedHome, ".clawdbot");
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
describe("msteams polls", () => {
|
||||
beforeEach(() => {
|
||||
setMSTeamsRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("builds poll cards with fallback text", () => {
|
||||
const card = buildMSTeamsPollCard({
|
||||
question: "Lunch?",
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import {
|
||||
createReplyDispatcherWithTyping,
|
||||
danger,
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
type ClawdbotConfig,
|
||||
type MSTeamsReplyStyle,
|
||||
type RuntimeEnv,
|
||||
import type {
|
||||
ClawdbotConfig,
|
||||
MSTeamsReplyStyle,
|
||||
RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import {
|
||||
@@ -20,6 +16,7 @@ import {
|
||||
} from "./messenger.js";
|
||||
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
export function createMSTeamsReplyDispatcher(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
@@ -34,6 +31,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
textLimit: number;
|
||||
onSentMessageIds?: (ids: string[]) => void;
|
||||
}) {
|
||||
const core = getMSTeamsRuntime();
|
||||
const sendTypingIndicator = async () => {
|
||||
try {
|
||||
await params.context.sendActivities([{ type: "typing" }]);
|
||||
@@ -42,9 +40,12 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
}
|
||||
};
|
||||
|
||||
return createReplyDispatcherWithTyping({
|
||||
responsePrefix: resolveEffectiveMessagesConfig(params.cfg, params.agentId).responsePrefix,
|
||||
humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||
return core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(
|
||||
params.cfg,
|
||||
params.agentId,
|
||||
).responsePrefix,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||
deliver: async (payload) => {
|
||||
const messages = renderReplyPayloadsToMessages([payload], {
|
||||
textChunkLimit: params.textLimit,
|
||||
@@ -74,7 +75,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const hint = formatMSTeamsSendErrorHint(classification);
|
||||
params.runtime.error?.(
|
||||
danger(`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`),
|
||||
`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
|
||||
);
|
||||
params.log.error("reply failed", {
|
||||
kind: info.kind,
|
||||
|
||||
14
extensions/msteams/src/runtime.ts
Normal file
14
extensions/msteams/src/runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setMSTeamsRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getMSTeamsRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("MSTeams runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import type { getChildLogger as getChildLoggerFn } from "clawdbot/plugin-sdk";
|
||||
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import type {
|
||||
MSTeamsConversationStore,
|
||||
StoredConversationReference,
|
||||
@@ -9,8 +8,10 @@ import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
let _log: ReturnType<typeof getChildLoggerFn> | undefined;
|
||||
const getLog = async (): Promise<ReturnType<typeof getChildLoggerFn>> => {
|
||||
type GetChildLogger = PluginRuntime["logging"]["getChildLogger"];
|
||||
|
||||
let _log: ReturnType<GetChildLogger> | undefined;
|
||||
const getLog = async (): Promise<ReturnType<GetChildLogger>> => {
|
||||
if (_log) return _log;
|
||||
const { getChildLogger } = await import("../logging.js");
|
||||
_log = getChildLogger({ name: "msteams:send" });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveStateDir } from "clawdbot/plugin-sdk";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
export type MSTeamsStorePathOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -15,6 +15,8 @@ export function resolveMSTeamsStorePath(params: MSTeamsStorePathOptions): string
|
||||
if (params.stateDir) return path.join(params.stateDir, params.filename);
|
||||
|
||||
const env = params.env ?? process.env;
|
||||
const stateDir = params.homedir ? resolveStateDir(env, params.homedir) : resolveStateDir(env);
|
||||
const stateDir = params.homedir
|
||||
? getMSTeamsRuntime().state.resolveStateDir(env, params.homedir)
|
||||
: getMSTeamsRuntime().state.resolveStateDir(env);
|
||||
return path.join(stateDir, params.filename);
|
||||
}
|
||||
|
||||
16
extensions/signal/index.ts
Normal file
16
extensions/signal/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { signalPlugin } from "./src/channel.js";
|
||||
import { setSignalRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "signal",
|
||||
name: "Signal",
|
||||
description: "Signal channel plugin",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setSignalRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: signalPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
9
extensions/signal/package.json
Normal file
9
extensions/signal/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@clawdbot/signal",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Signal channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,28 @@
|
||||
import { chunkText } from "../../auto-reply/chunk.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import {
|
||||
listSignalAccountIds,
|
||||
type ResolvedSignalAccount,
|
||||
resolveDefaultSignalAccountId,
|
||||
resolveSignalAccount,
|
||||
} from "../../signal/accounts.js";
|
||||
import { probeSignal } from "../../signal/probe.js";
|
||||
import { sendMessageSignal } from "../../signal/send.js";
|
||||
import { normalizeE164 } from "../../utils.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import { SignalConfigSchema } from "../../config/zod-schema.providers-core.js";
|
||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "./config-helpers.js";
|
||||
import { formatPairingApproveHint } from "./helpers.js";
|
||||
import { resolveChannelMediaMaxBytes } from "./media-limits.js";
|
||||
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize/signal.js";
|
||||
import { signalOnboardingAdapter } from "./onboarding/signal.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
listSignalAccountIds,
|
||||
looksLikeSignalTargetId,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "./setup-helpers.js";
|
||||
import type { ChannelPlugin } from "./types.js";
|
||||
normalizeAccountId,
|
||||
normalizeE164,
|
||||
normalizeSignalMessagingTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveChannelMediaMaxBytes,
|
||||
resolveDefaultSignalAccountId,
|
||||
resolveSignalAccount,
|
||||
setAccountEnabledInConfigSection,
|
||||
signalOnboardingAdapter,
|
||||
SignalConfigSchema,
|
||||
type ChannelPlugin,
|
||||
type ResolvedSignalAccount,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getSignalRuntime } from "./runtime.js";
|
||||
|
||||
const meta = getChatChannelMeta("signal");
|
||||
|
||||
@@ -39,7 +36,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
idLabel: "signalNumber",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""),
|
||||
notifyApproval: async ({ id }) => {
|
||||
await sendMessageSignal(id, PAIRING_APPROVED_MESSAGE);
|
||||
await getSignalRuntime().channel.signal.sendMessageSignal(id, PAIRING_APPROVED_MESSAGE);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
@@ -199,10 +196,10 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkText,
|
||||
chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit),
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||
const send = deps?.sendSignal ?? sendMessageSignal;
|
||||
const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
|
||||
const maxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||
@@ -217,7 +214,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
return { channel: "signal", ...result };
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
|
||||
const send = deps?.sendSignal ?? sendMessageSignal;
|
||||
const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
|
||||
const maxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||
@@ -266,7 +263,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) => {
|
||||
const baseUrl = account.baseUrl;
|
||||
return await probeSignal(baseUrl, timeoutMs);
|
||||
return await getSignalRuntime().channel.signal.probeSignal(baseUrl, timeoutMs);
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
@@ -292,8 +289,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting provider (${account.baseUrl})`);
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorSignalProvider } = await import("../../signal/index.js");
|
||||
return monitorSignalProvider({
|
||||
return getSignalRuntime().channel.signal.monitorSignalProvider({
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
14
extensions/signal/src/runtime.ts
Normal file
14
extensions/signal/src/runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setSignalRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getSignalRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Signal runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
16
extensions/slack/index.ts
Normal file
16
extensions/slack/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { slackPlugin } from "./src/channel.js";
|
||||
import { setSlackRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
description: "Slack channel plugin",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setSlackRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: slackPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
9
extensions/slack/package.json
Normal file
9
extensions/slack/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@clawdbot/slack",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Slack channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,34 @@
|
||||
import { createActionGate, readNumberParam, readStringParam } from "../../agents/tools/common.js";
|
||||
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import {
|
||||
listEnabledSlackAccounts,
|
||||
listSlackAccountIds,
|
||||
type ResolvedSlackAccount,
|
||||
resolveDefaultSlackAccountId,
|
||||
resolveSlackAccount,
|
||||
} from "../../slack/accounts.js";
|
||||
import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js";
|
||||
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
|
||||
import { probeSlack } from "../../slack/probe.js";
|
||||
import { sendMessageSlack } from "../../slack/send.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import { SlackConfigSchema } from "../../config/zod-schema.providers-core.js";
|
||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "./config-helpers.js";
|
||||
import { resolveSlackGroupRequireMention } from "./group-mentions.js";
|
||||
import { formatPairingApproveHint } from "./helpers.js";
|
||||
import { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./normalize/slack.js";
|
||||
import { slackOnboardingAdapter } from "./onboarding/slack.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "./setup-helpers.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
createActionGate,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
listEnabledSlackAccounts,
|
||||
listSlackAccountIds,
|
||||
listSlackDirectoryGroupsFromConfig,
|
||||
listSlackDirectoryPeersFromConfig,
|
||||
} from "./directory-config.js";
|
||||
import {
|
||||
listSlackDirectoryGroupsLive,
|
||||
listSlackDirectoryPeersLive,
|
||||
} from "../../slack/directory-live.js";
|
||||
looksLikeSlackTargetId,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
normalizeSlackMessagingTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
readNumberParam,
|
||||
readStringParam,
|
||||
resolveDefaultSlackAccountId,
|
||||
resolveSlackAccount,
|
||||
resolveSlackGroupRequireMention,
|
||||
setAccountEnabledInConfigSection,
|
||||
slackOnboardingAdapter,
|
||||
SlackConfigSchema,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelPlugin,
|
||||
type ResolvedSlackAccount,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getSlackRuntime } from "./runtime.js";
|
||||
|
||||
const meta = getChatChannelMeta("slack");
|
||||
|
||||
@@ -64,7 +55,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
idLabel: "slackUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""),
|
||||
notifyApproval: async ({ id }) => {
|
||||
const cfg = loadConfig();
|
||||
const cfg = getSlackRuntime().config.loadConfig();
|
||||
const account = resolveSlackAccount({
|
||||
cfg,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
@@ -73,11 +64,11 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
const botToken = account.botToken?.trim();
|
||||
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||
if (tokenOverride) {
|
||||
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE, {
|
||||
await getSlackRuntime().channel.slack.sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE, {
|
||||
token: tokenOverride,
|
||||
});
|
||||
} else {
|
||||
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE);
|
||||
await getSlackRuntime().channel.slack.sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -197,8 +188,9 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
self: async () => null,
|
||||
listPeers: async (params) => listSlackDirectoryPeersFromConfig(params),
|
||||
listGroups: async (params) => listSlackDirectoryGroupsFromConfig(params),
|
||||
listPeersLive: async (params) => listSlackDirectoryPeersLive(params),
|
||||
listGroupsLive: async (params) => listSlackDirectoryGroupsLive(params),
|
||||
listPeersLive: async (params) => getSlackRuntime().channel.slack.listDirectoryPeersLive(params),
|
||||
listGroupsLive: async (params) =>
|
||||
getSlackRuntime().channel.slack.listDirectoryGroupsLive(params),
|
||||
},
|
||||
resolver: {
|
||||
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
|
||||
@@ -212,7 +204,10 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
}));
|
||||
}
|
||||
if (kind === "group") {
|
||||
const resolved = await resolveSlackChannelAllowlist({ token, entries: inputs });
|
||||
const resolved = await getSlackRuntime().channel.slack.resolveChannelAllowlist({
|
||||
token,
|
||||
entries: inputs,
|
||||
});
|
||||
return resolved.map((entry) => ({
|
||||
input: entry.input,
|
||||
resolved: entry.resolved,
|
||||
@@ -221,7 +216,10 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
note: entry.archived ? "archived" : undefined,
|
||||
}));
|
||||
}
|
||||
const resolved = await resolveSlackUserAllowlist({ token, entries: inputs });
|
||||
const resolved = await getSlackRuntime().channel.slack.resolveUserAllowlist({
|
||||
token,
|
||||
entries: inputs,
|
||||
});
|
||||
return resolved.map((entry) => ({
|
||||
input: entry.input,
|
||||
resolved: entry.resolved,
|
||||
@@ -240,10 +238,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
const isActionEnabled = (key: string, defaultValue = true) => {
|
||||
for (const account of accounts) {
|
||||
const gate = createActionGate(
|
||||
(account.actions ?? cfg.channels?.slack?.actions) as Record<
|
||||
string,
|
||||
boolean | undefined
|
||||
>,
|
||||
(account.actions ?? cfg.channels?.slack?.actions) as Record<string, boolean | undefined>,
|
||||
);
|
||||
if (gate(key, defaultValue)) return true;
|
||||
}
|
||||
@@ -290,7 +285,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
return await handleSlackAction(
|
||||
return await getSlackRuntime().channel.slack.handleSlackAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to,
|
||||
@@ -310,7 +305,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
});
|
||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
|
||||
return await handleSlackAction(
|
||||
return await getSlackRuntime().channel.slack.handleSlackAction(
|
||||
{
|
||||
action: "react",
|
||||
channelId: resolveChannelId(),
|
||||
@@ -328,7 +323,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
required: true,
|
||||
});
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleSlackAction(
|
||||
return await getSlackRuntime().channel.slack.handleSlackAction(
|
||||
{
|
||||
action: "reactions",
|
||||
channelId: resolveChannelId(),
|
||||
@@ -342,7 +337,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
|
||||
if (action === "read") {
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleSlackAction(
|
||||
return await getSlackRuntime().channel.slack.handleSlackAction(
|
||||
{
|
||||
action: "readMessages",
|
||||
channelId: resolveChannelId(),
|
||||
@@ -360,7 +355,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
required: true,
|
||||
});
|
||||
const content = readStringParam(params, "message", { required: true });
|
||||
return await handleSlackAction(
|
||||
return await getSlackRuntime().channel.slack.handleSlackAction(
|
||||
{
|
||||
action: "editMessage",
|
||||
channelId: resolveChannelId(),
|
||||
@@ -376,7 +371,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
return await handleSlackAction(
|
||||
return await getSlackRuntime().channel.slack.handleSlackAction(
|
||||
{
|
||||
action: "deleteMessage",
|
||||
channelId: resolveChannelId(),
|
||||
@@ -392,7 +387,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
action === "list-pins"
|
||||
? undefined
|
||||
: readStringParam(params, "messageId", { required: true });
|
||||
return await handleSlackAction(
|
||||
return await getSlackRuntime().channel.slack.handleSlackAction(
|
||||
{
|
||||
action:
|
||||
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
|
||||
@@ -406,14 +401,14 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
|
||||
if (action === "member-info") {
|
||||
const userId = readStringParam(params, "userId", { required: true });
|
||||
return await handleSlackAction(
|
||||
return await getSlackRuntime().channel.slack.handleSlackAction(
|
||||
{ action: "memberInfo", userId, accountId: accountId ?? undefined },
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "emoji-list") {
|
||||
return await handleSlackAction(
|
||||
return await getSlackRuntime().channel.slack.handleSlackAction(
|
||||
{ action: "emojiList", accountId: accountId ?? undefined },
|
||||
cfg,
|
||||
);
|
||||
@@ -498,7 +493,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
chunker: null,
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => {
|
||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||
const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack;
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const token = getTokenForOperation(account, "write");
|
||||
const botToken = account.botToken?.trim();
|
||||
@@ -511,7 +506,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
return { channel: "slack", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => {
|
||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||
const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack;
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const token = getTokenForOperation(account, "write");
|
||||
const botToken = account.botToken?.trim();
|
||||
@@ -547,7 +542,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
probeAccount: async ({ account, timeoutMs }) => {
|
||||
const token = account.botToken?.trim();
|
||||
if (!token) return { ok: false, error: "missing token" };
|
||||
return await probeSlack(token, timeoutMs);
|
||||
return await getSlackRuntime().channel.slack.probeSlack(token, timeoutMs);
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
||||
const configured = Boolean(account.botToken && account.appToken);
|
||||
@@ -574,9 +569,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
const botToken = account.botToken?.trim();
|
||||
const appToken = account.appToken?.trim();
|
||||
ctx.log?.info(`[${account.accountId}] starting provider`);
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorSlackProvider } = await import("../../slack/index.js");
|
||||
return monitorSlackProvider({
|
||||
return getSlackRuntime().channel.slack.monitorSlackProvider({
|
||||
botToken: botToken ?? "",
|
||||
appToken: appToken ?? "",
|
||||
accountId: account.accountId,
|
||||
14
extensions/slack/src/runtime.ts
Normal file
14
extensions/slack/src/runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setSlackRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getSlackRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Slack runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
16
extensions/telegram/index.ts
Normal file
16
extensions/telegram/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { telegramPlugin } from "./src/channel.js";
|
||||
import { setTelegramRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "telegram",
|
||||
name: "Telegram",
|
||||
description: "Telegram channel plugin",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setTelegramRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: telegramPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
9
extensions/telegram/package.json
Normal file
9
extensions/telegram/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@clawdbot/telegram",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Telegram channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,43 @@
|
||||
import { chunkMarkdownText } from "../../auto-reply/chunk.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { writeConfigFile } from "../../config/config.js";
|
||||
import { shouldLogVerbose } from "../../globals.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import {
|
||||
listTelegramAccountIds,
|
||||
type ResolvedTelegramAccount,
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
} from "../../telegram/accounts.js";
|
||||
import {
|
||||
auditTelegramGroupMembership,
|
||||
collectTelegramUnmentionedGroupIds,
|
||||
} from "../../telegram/audit.js";
|
||||
import { probeTelegram } from "../../telegram/probe.js";
|
||||
import { sendMessageTelegram } from "../../telegram/send.js";
|
||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import { TelegramConfigSchema } from "../../config/zod-schema.providers-core.js";
|
||||
import { telegramMessageActions } from "./actions/telegram.js";
|
||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "./config-helpers.js";
|
||||
import { resolveTelegramGroupRequireMention } from "./group-mentions.js";
|
||||
import { formatPairingApproveHint } from "./helpers.js";
|
||||
import {
|
||||
looksLikeTelegramTargetId,
|
||||
normalizeTelegramMessagingTarget,
|
||||
} from "./normalize/telegram.js";
|
||||
import { telegramOnboardingAdapter } from "./onboarding/telegram.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "./setup-helpers.js";
|
||||
import { collectTelegramStatusIssues } from "./status-issues/telegram.js";
|
||||
import type { ChannelPlugin } from "./types.js";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
collectTelegramStatusIssues,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
listTelegramAccountIds,
|
||||
listTelegramDirectoryGroupsFromConfig,
|
||||
listTelegramDirectoryPeersFromConfig,
|
||||
} from "./directory-config.js";
|
||||
looksLikeTelegramTargetId,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
normalizeTelegramMessagingTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
resolveTelegramGroupRequireMention,
|
||||
setAccountEnabledInConfigSection,
|
||||
telegramOnboardingAdapter,
|
||||
TelegramConfigSchema,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
type ClawdbotConfig,
|
||||
type ResolvedTelegramAccount,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getTelegramRuntime } from "./runtime.js";
|
||||
|
||||
const meta = getChatChannelMeta("telegram");
|
||||
|
||||
const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: (ctx) => getTelegramRuntime().channel.telegram.messageActions.listActions(ctx),
|
||||
extractToolSend: (ctx) =>
|
||||
getTelegramRuntime().channel.telegram.messageActions.extractToolSend(ctx),
|
||||
handleAction: async (ctx) =>
|
||||
await getTelegramRuntime().channel.telegram.messageActions.handleAction(ctx),
|
||||
};
|
||||
|
||||
function parseReplyToMessageId(replyToId?: string | null) {
|
||||
if (!replyToId) return undefined;
|
||||
const parsed = Number.parseInt(replyToId, 10);
|
||||
@@ -72,9 +65,11 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
idLabel: "telegramUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const { token } = resolveTelegramToken(cfg);
|
||||
const { token } = getTelegramRuntime().channel.telegram.resolveTelegramToken(cfg);
|
||||
if (!token) throw new Error("telegram token not configured");
|
||||
await sendMessageTelegram(id, PAIRING_APPROVED_MESSAGE, { token });
|
||||
await getTelegramRuntime().channel.telegram.sendMessageTelegram(id, PAIRING_APPROVED_MESSAGE, {
|
||||
token,
|
||||
});
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
@@ -253,10 +248,11 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkMarkdownText,
|
||||
chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
|
||||
const send = deps?.sendTelegram ?? sendMessageTelegram;
|
||||
const send =
|
||||
deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||
const replyToMessageId = parseReplyToMessageId(replyToId);
|
||||
const messageThreadId = parseThreadId(threadId);
|
||||
const result = await send(to, text, {
|
||||
@@ -268,7 +264,8 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
return { channel: "telegram", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId }) => {
|
||||
const send = deps?.sendTelegram ?? sendMessageTelegram;
|
||||
const send =
|
||||
deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||
const replyToMessageId = parseReplyToMessageId(replyToId);
|
||||
const messageThreadId = parseThreadId(threadId);
|
||||
const result = await send(to, text, {
|
||||
@@ -302,13 +299,17 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
probeTelegram(account.token, timeoutMs, account.config.proxy),
|
||||
getTelegramRuntime().channel.telegram.probeTelegram(
|
||||
account.token,
|
||||
timeoutMs,
|
||||
account.config.proxy,
|
||||
),
|
||||
auditAccount: async ({ account, timeoutMs, probe, cfg }) => {
|
||||
const groups =
|
||||
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
|
||||
cfg.channels?.telegram?.groups;
|
||||
const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } =
|
||||
collectTelegramUnmentionedGroupIds(groups);
|
||||
getTelegramRuntime().channel.telegram.collectUnmentionedGroupIds(groups);
|
||||
if (!groupIds.length && unresolvedGroups === 0 && !hasWildcardUnmentionedGroups) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -327,7 +328,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
const audit = await auditTelegramGroupMembership({
|
||||
const audit = await getTelegramRuntime().channel.telegram.auditGroupMembership({
|
||||
token: account.token,
|
||||
botId,
|
||||
groupIds,
|
||||
@@ -377,18 +378,20 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
const token = account.token.trim();
|
||||
let telegramBotLabel = "";
|
||||
try {
|
||||
const probe = await probeTelegram(token, 2500, account.config.proxy);
|
||||
const probe = await getTelegramRuntime().channel.telegram.probeTelegram(
|
||||
token,
|
||||
2500,
|
||||
account.config.proxy,
|
||||
);
|
||||
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
||||
if (username) telegramBotLabel = ` (@${username})`;
|
||||
} catch (err) {
|
||||
if (shouldLogVerbose()) {
|
||||
if (getTelegramRuntime().logging.shouldLogVerbose()) {
|
||||
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
ctx.log?.info(`[${account.accountId}] starting provider${telegramBotLabel}`);
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorTelegramProvider } = await import("../../telegram/monitor.js");
|
||||
return monitorTelegramProvider({
|
||||
return getTelegramRuntime().channel.telegram.monitorTelegramProvider({
|
||||
token,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
@@ -464,7 +467,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
});
|
||||
const loggedOut = resolved.tokenSource === "none";
|
||||
if (changed) {
|
||||
await writeConfigFile(nextCfg);
|
||||
await getTelegramRuntime().config.writeConfigFile(nextCfg);
|
||||
}
|
||||
return { cleared, envToken: Boolean(envToken), loggedOut };
|
||||
},
|
||||
14
extensions/telegram/src/runtime.ts
Normal file
14
extensions/telegram/src/runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setTelegramRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getTelegramRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Telegram runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
16
extensions/whatsapp/index.ts
Normal file
16
extensions/whatsapp/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { whatsappPlugin } from "./src/channel.js";
|
||||
import { setWhatsAppRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "whatsapp",
|
||||
name: "WhatsApp",
|
||||
description: "WhatsApp channel plugin",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setWhatsAppRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: whatsappPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
9
extensions/whatsapp/package.json
Normal file
9
extensions/whatsapp/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@clawdbot/whatsapp",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot WhatsApp channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,35 @@
|
||||
import { createActionGate, readStringParam } from "../../agents/tools/common.js";
|
||||
import { handleWhatsAppAction } from "../../agents/tools/whatsapp-actions.js";
|
||||
import { chunkText } from "../../auto-reply/chunk.js";
|
||||
import { shouldLogVerbose } from "../../globals.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { normalizeE164 } from "../../utils.js";
|
||||
import {
|
||||
listWhatsAppAccountIds,
|
||||
type ResolvedWhatsAppAccount,
|
||||
resolveDefaultWhatsAppAccountId,
|
||||
resolveWhatsAppAccount,
|
||||
} from "../../web/accounts.js";
|
||||
import { getActiveWebListener } from "../../web/active-listener.js";
|
||||
import {
|
||||
getWebAuthAgeMs,
|
||||
logoutWeb,
|
||||
logWebSelfId,
|
||||
readWebSelfId,
|
||||
webAuthExists,
|
||||
} from "../../web/auth-store.js";
|
||||
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import { WhatsAppConfigSchema } from "../../config/zod-schema.providers-whatsapp.js";
|
||||
import { buildChannelConfigSchema } from "./config-schema.js";
|
||||
import { createWhatsAppLoginTool } from "./agent-tools/whatsapp-login.js";
|
||||
import { resolveWhatsAppGroupRequireMention } from "./group-mentions.js";
|
||||
import { formatPairingApproveHint } from "./helpers.js";
|
||||
import {
|
||||
looksLikeWhatsAppTargetId,
|
||||
normalizeWhatsAppMessagingTarget,
|
||||
} from "./normalize/whatsapp.js";
|
||||
import { whatsappOnboardingAdapter } from "./onboarding/whatsapp.js";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "./setup-helpers.js";
|
||||
import { collectWhatsAppStatusIssues } from "./status-issues/whatsapp.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
|
||||
import { resolveWhatsAppHeartbeatRecipients } from "./whatsapp-heartbeat.js";
|
||||
import { missingTargetError } from "../../infra/outbound/target-errors.js";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
collectWhatsAppStatusIssues,
|
||||
createActionGate,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
isWhatsAppGroupJid,
|
||||
listWhatsAppAccountIds,
|
||||
listWhatsAppDirectoryGroupsFromConfig,
|
||||
listWhatsAppDirectoryPeersFromConfig,
|
||||
} from "./directory-config.js";
|
||||
looksLikeWhatsAppTargetId,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
missingTargetError,
|
||||
normalizeAccountId,
|
||||
normalizeE164,
|
||||
normalizeWhatsAppMessagingTarget,
|
||||
normalizeWhatsAppTarget,
|
||||
readStringParam,
|
||||
resolveDefaultWhatsAppAccountId,
|
||||
resolveWhatsAppAccount,
|
||||
resolveWhatsAppGroupRequireMention,
|
||||
resolveWhatsAppHeartbeatRecipients,
|
||||
whatsappOnboardingAdapter,
|
||||
WhatsAppConfigSchema,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelPlugin,
|
||||
type ResolvedWhatsAppAccount,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getWhatsAppRuntime } from "./runtime.js";
|
||||
|
||||
const meta = getChatChannelMeta("whatsapp");
|
||||
|
||||
@@ -58,7 +45,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
preferSessionLookupForAnnounceTarget: true,
|
||||
},
|
||||
onboarding: whatsappOnboardingAdapter,
|
||||
agentTools: () => [createWhatsAppLoginTool()],
|
||||
agentTools: () => [getWhatsAppRuntime().channel.whatsapp.createLoginTool()],
|
||||
pairing: {
|
||||
idLabel: "whatsappSenderId",
|
||||
},
|
||||
@@ -113,7 +100,8 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
},
|
||||
isEnabled: (account, cfg) => account.enabled !== false && cfg.web?.enabled !== false,
|
||||
disabledReason: () => "disabled",
|
||||
isConfigured: async (account) => await webAuthExists(account.authDir),
|
||||
isConfigured: async (account) =>
|
||||
await getWhatsAppRuntime().channel.whatsapp.webAuthExists(account.authDir),
|
||||
unconfiguredReason: () => "not linked",
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
@@ -235,7 +223,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
directory: {
|
||||
self: async ({ cfg, accountId }) => {
|
||||
const account = resolveWhatsAppAccount({ cfg, accountId });
|
||||
const { e164, jid } = readWebSelfId(account.authDir);
|
||||
const { e164, jid } = getWhatsAppRuntime().channel.whatsapp.readWebSelfId(account.authDir);
|
||||
const id = e164 ?? jid;
|
||||
if (!id) return null;
|
||||
return {
|
||||
@@ -267,7 +255,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
});
|
||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
|
||||
return await handleWhatsAppAction(
|
||||
return await getWhatsAppRuntime().channel.whatsapp.handleWhatsAppAction(
|
||||
{
|
||||
action: "react",
|
||||
chatJid:
|
||||
@@ -285,7 +273,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "gateway",
|
||||
chunker: chunkText,
|
||||
chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit),
|
||||
textChunkLimit: 4000,
|
||||
pollMaxOptions: 12,
|
||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
||||
@@ -338,7 +326,8 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
};
|
||||
},
|
||||
sendText: async ({ to, text, accountId, deps, gifPlayback }) => {
|
||||
const send = deps?.sendWhatsApp ?? sendMessageWhatsApp;
|
||||
const send =
|
||||
deps?.sendWhatsApp ?? getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp;
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
@@ -347,7 +336,8 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
return { channel: "whatsapp", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, gifPlayback }) => {
|
||||
const send = deps?.sendWhatsApp ?? sendMessageWhatsApp;
|
||||
const send =
|
||||
deps?.sendWhatsApp ?? getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp;
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
@@ -357,16 +347,20 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
return { channel: "whatsapp", ...result };
|
||||
},
|
||||
sendPoll: async ({ to, poll, accountId }) =>
|
||||
await sendPollWhatsApp(to, poll, {
|
||||
verbose: shouldLogVerbose(),
|
||||
await getWhatsAppRuntime().channel.whatsapp.sendPollWhatsApp(to, poll, {
|
||||
verbose: getWhatsAppRuntime().logging.shouldLogVerbose(),
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
},
|
||||
auth: {
|
||||
login: async ({ cfg, accountId, runtime, verbose }) => {
|
||||
const resolvedAccountId = accountId?.trim() || resolveDefaultWhatsAppAccountId(cfg);
|
||||
const { loginWeb } = await import("../../web/login.js");
|
||||
await loginWeb(Boolean(verbose), undefined, runtime, resolvedAccountId);
|
||||
await getWhatsAppRuntime().channel.whatsapp.loginWeb(
|
||||
Boolean(verbose),
|
||||
undefined,
|
||||
runtime,
|
||||
resolvedAccountId,
|
||||
);
|
||||
},
|
||||
},
|
||||
heartbeat: {
|
||||
@@ -375,13 +369,14 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
return { ok: false, reason: "whatsapp-disabled" };
|
||||
}
|
||||
const account = resolveWhatsAppAccount({ cfg, accountId });
|
||||
const authExists = await (deps?.webAuthExists ?? webAuthExists)(account.authDir);
|
||||
const authExists = await (deps?.webAuthExists ??
|
||||
getWhatsAppRuntime().channel.whatsapp.webAuthExists)(account.authDir);
|
||||
if (!authExists) {
|
||||
return { ok: false, reason: "whatsapp-not-linked" };
|
||||
}
|
||||
const listenerActive = deps?.hasActiveWebListener
|
||||
? deps.hasActiveWebListener()
|
||||
: Boolean(getActiveWebListener());
|
||||
: Boolean(getWhatsAppRuntime().channel.whatsapp.getActiveWebListener());
|
||||
if (!listenerActive) {
|
||||
return { ok: false, reason: "whatsapp-not-running" };
|
||||
}
|
||||
@@ -408,10 +403,16 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
typeof snapshot.linked === "boolean"
|
||||
? snapshot.linked
|
||||
: authDir
|
||||
? await webAuthExists(authDir)
|
||||
? await getWhatsAppRuntime().channel.whatsapp.webAuthExists(authDir)
|
||||
: false;
|
||||
const authAgeMs = linked && authDir ? getWebAuthAgeMs(authDir) : null;
|
||||
const self = linked && authDir ? readWebSelfId(authDir) : { e164: null, jid: null };
|
||||
const authAgeMs =
|
||||
linked && authDir
|
||||
? getWhatsAppRuntime().channel.whatsapp.getWebAuthAgeMs(authDir)
|
||||
: null;
|
||||
const self =
|
||||
linked && authDir
|
||||
? getWhatsAppRuntime().channel.whatsapp.readWebSelfId(authDir)
|
||||
: { e164: null, jid: null };
|
||||
return {
|
||||
configured: linked,
|
||||
linked,
|
||||
@@ -428,7 +429,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
};
|
||||
},
|
||||
buildAccountSnapshot: async ({ account, runtime }) => {
|
||||
const linked = await webAuthExists(account.authDir);
|
||||
const linked = await getWhatsAppRuntime().channel.whatsapp.webAuthExists(account.authDir);
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
@@ -449,19 +450,21 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
},
|
||||
resolveAccountState: ({ configured }) => (configured ? "linked" : "not linked"),
|
||||
logSelfId: ({ account, runtime, includeChannelPrefix }) => {
|
||||
logWebSelfId(account.authDir, runtime, includeChannelPrefix);
|
||||
getWhatsAppRuntime().channel.whatsapp.logWebSelfId(
|
||||
account.authDir,
|
||||
runtime,
|
||||
includeChannelPrefix,
|
||||
);
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const { e164, jid } = readWebSelfId(account.authDir);
|
||||
const { e164, jid } = getWhatsAppRuntime().channel.whatsapp.readWebSelfId(account.authDir);
|
||||
const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown";
|
||||
ctx.log?.info(`[${account.accountId}] starting provider (${identity})`);
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorWebChannel } = await import("../web/index.js");
|
||||
return monitorWebChannel(
|
||||
shouldLogVerbose(),
|
||||
return getWhatsAppRuntime().channel.whatsapp.monitorWebChannel(
|
||||
getWhatsAppRuntime().logging.shouldLogVerbose(),
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
@@ -474,22 +477,16 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
);
|
||||
},
|
||||
loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) =>
|
||||
await (async () => {
|
||||
const { startWebLoginWithQr } = await import("../../web/login-qr.js");
|
||||
return await startWebLoginWithQr({
|
||||
accountId,
|
||||
force,
|
||||
timeoutMs,
|
||||
verbose,
|
||||
});
|
||||
})(),
|
||||
await getWhatsAppRuntime().channel.whatsapp.startWebLoginWithQr({
|
||||
accountId,
|
||||
force,
|
||||
timeoutMs,
|
||||
verbose,
|
||||
}),
|
||||
loginWithQrWait: async ({ accountId, timeoutMs }) =>
|
||||
await (async () => {
|
||||
const { waitForWebLogin } = await import("../../web/login-qr.js");
|
||||
return await waitForWebLogin({ accountId, timeoutMs });
|
||||
})(),
|
||||
await getWhatsAppRuntime().channel.whatsapp.waitForWebLogin({ accountId, timeoutMs }),
|
||||
logoutAccount: async ({ account, runtime }) => {
|
||||
const cleared = await logoutWeb({
|
||||
const cleared = await getWhatsAppRuntime().channel.whatsapp.logoutWeb({
|
||||
authDir: account.authDir,
|
||||
isLegacyAuthDir: account.isLegacyAuthDir,
|
||||
runtime,
|
||||
14
extensions/whatsapp/src/runtime.ts
Normal file
14
extensions/whatsapp/src/runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setWhatsAppRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getWhatsAppRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("WhatsApp runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -1,15 +1,6 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import {
|
||||
finalizeInboundContext,
|
||||
formatAgentEnvelope,
|
||||
isControlCommandMessage,
|
||||
recordSessionMetaFromInbound,
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
resolveStorePath,
|
||||
shouldComputeCommandAuthorized,
|
||||
type ClawdbotConfig,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
import {
|
||||
@@ -448,7 +439,10 @@ async function processMessageWithPipeline(params: {
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
||||
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
||||
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
|
||||
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(
|
||||
rawBody,
|
||||
config,
|
||||
);
|
||||
const storeAllowFrom =
|
||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
||||
? await core.channel.pairing.readAllowFromStore("zalo").catch(() => [])
|
||||
@@ -457,7 +451,7 @@ async function processMessageWithPipeline(params: {
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
const commandAuthorized = shouldComputeAuth
|
||||
? resolveCommandAuthorizedFromAuthorizers({
|
||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }],
|
||||
})
|
||||
@@ -526,20 +520,24 @@ async function processMessageWithPipeline(params: {
|
||||
},
|
||||
});
|
||||
|
||||
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
|
||||
if (
|
||||
isGroup &&
|
||||
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
||||
commandAuthorized !== true
|
||||
) {
|
||||
logVerbose(core, runtime, `zalo: drop control command from unauthorized sender ${senderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||
const body = formatAgentEnvelope({
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Zalo",
|
||||
from: fromLabel,
|
||||
timestamp: date ? date * 1000 : undefined,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
@@ -562,10 +560,10 @@ async function processMessageWithPipeline(params: {
|
||||
OriginatingTo: `zalo:${chatId}`,
|
||||
});
|
||||
|
||||
const storePath = resolveStorePath(config.session?.store, {
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void recordSessionMetaFromInbound({
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
finalizeInboundContext,
|
||||
formatAgentEnvelope,
|
||||
isControlCommandMessage,
|
||||
mergeAllowlist,
|
||||
recordSessionMetaFromInbound,
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
resolveStorePath,
|
||||
shouldComputeCommandAuthorized,
|
||||
summarizeMapping,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk";
|
||||
import { sendMessageZalouser } from "./send.js";
|
||||
import type {
|
||||
ResolvedZalouserAccount,
|
||||
@@ -193,7 +183,10 @@ async function processMessage(
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
||||
const rawBody = content.trim();
|
||||
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
|
||||
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(
|
||||
rawBody,
|
||||
config,
|
||||
);
|
||||
const storeAllowFrom =
|
||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
||||
? await core.channel.pairing.readAllowFromStore("zalouser").catch(() => [])
|
||||
@@ -202,7 +195,7 @@ async function processMessage(
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
const commandAuthorized = shouldComputeAuth
|
||||
? resolveCommandAuthorizedFromAuthorizers({
|
||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }],
|
||||
})
|
||||
@@ -258,7 +251,11 @@ async function processMessage(
|
||||
}
|
||||
}
|
||||
|
||||
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
|
||||
if (
|
||||
isGroup &&
|
||||
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
||||
commandAuthorized !== true
|
||||
) {
|
||||
logVerbose(core, runtime, `zalouser: drop control command from unauthorized sender ${senderId}`);
|
||||
return;
|
||||
}
|
||||
@@ -277,14 +274,14 @@ async function processMessage(
|
||||
});
|
||||
|
||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||
const body = formatAgentEnvelope({
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Zalo Personal",
|
||||
from: fromLabel,
|
||||
timestamp: timestamp ? timestamp * 1000 : undefined,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
@@ -304,10 +301,10 @@ async function processMessage(
|
||||
OriginatingTo: `zalouser:${chatId}`,
|
||||
});
|
||||
|
||||
const storePath = resolveStorePath(config.session?.store, {
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void recordSessionMetaFromInbound({
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
|
||||
@@ -81,8 +81,8 @@
|
||||
"start": "tsx src/entry.ts",
|
||||
"clawdbot": "tsx src/entry.ts",
|
||||
"gateway:watch": "tsx watch src/entry.ts gateway --force",
|
||||
"gateway:dev": "tsx src/entry.ts --dev gateway",
|
||||
"gateway:dev:reset": "tsx src/entry.ts --dev gateway --reset",
|
||||
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 tsx src/entry.ts --dev gateway",
|
||||
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 tsx src/entry.ts --dev gateway --reset",
|
||||
"tui": "tsx src/entry.ts tui",
|
||||
"tui:dev": "CLAWDBOT_PROFILE=dev tsx src/entry.ts tui",
|
||||
"clawdbot:rpc": "tsx src/entry.ts agent --mode rpc --json",
|
||||
@@ -140,6 +140,7 @@
|
||||
},
|
||||
"packageManager": "pnpm@10.23.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.13.0",
|
||||
"@buape/carbon": "0.0.0-beta-20260110172854",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
|
||||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@@ -13,6 +13,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk':
|
||||
specifier: 0.13.0
|
||||
version: 0.13.0(zod@4.3.5)
|
||||
'@buape/carbon':
|
||||
specifier: 0.0.0-beta-20260110172854
|
||||
version: 0.0.0-beta-20260110172854(hono@4.11.4)
|
||||
@@ -242,10 +245,14 @@ importers:
|
||||
|
||||
extensions/copilot-proxy: {}
|
||||
|
||||
extensions/discord: {}
|
||||
|
||||
extensions/google-antigravity-auth: {}
|
||||
|
||||
extensions/google-gemini-cli-auth: {}
|
||||
|
||||
extensions/imessage: {}
|
||||
|
||||
extensions/matrix:
|
||||
dependencies:
|
||||
clawdbot:
|
||||
@@ -297,6 +304,12 @@ importers:
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2
|
||||
|
||||
extensions/signal: {}
|
||||
|
||||
extensions/slack: {}
|
||||
|
||||
extensions/telegram: {}
|
||||
|
||||
extensions/voice-call:
|
||||
dependencies:
|
||||
'@sinclair/typebox':
|
||||
@@ -309,6 +322,8 @@ importers:
|
||||
specifier: ^4.3.5
|
||||
version: 4.3.5
|
||||
|
||||
extensions/whatsapp: {}
|
||||
|
||||
extensions/zalo:
|
||||
dependencies:
|
||||
clawdbot:
|
||||
@@ -357,6 +372,11 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@agentclientprotocol/sdk@0.13.0':
|
||||
resolution: {integrity: sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw==}
|
||||
peerDependencies:
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
'@anthropic-ai/sdk@0.71.2':
|
||||
resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==}
|
||||
hasBin: true
|
||||
@@ -4581,6 +4601,10 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@agentclientprotocol/sdk@0.13.0(zod@4.3.5)':
|
||||
dependencies:
|
||||
zod: 4.3.5
|
||||
|
||||
'@anthropic-ai/sdk@0.71.2(zod@4.3.5)':
|
||||
dependencies:
|
||||
json-schema-to-ts: 3.1.1
|
||||
|
||||
@@ -3,6 +3,7 @@ set -euo pipefail
|
||||
|
||||
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
|
||||
MODELS_MODE="${CLAWDBOT_E2E_MODELS:-both}" # both|openai|anthropic
|
||||
INSTALL_TAG="${CLAWDBOT_INSTALL_TAG:-latest}"
|
||||
E2E_PREVIOUS_VERSION="${CLAWDBOT_INSTALL_E2E_PREVIOUS:-}"
|
||||
SKIP_PREVIOUS="${CLAWDBOT_INSTALL_E2E_SKIP_PREVIOUS:-0}"
|
||||
OPENAI_API_KEY="${OPENAI_API_KEY:-}"
|
||||
@@ -32,7 +33,11 @@ elif [[ "$MODELS_MODE" == "anthropic" && -z "$ANTHROPIC_API_TOKEN" && -z "$ANTHR
|
||||
fi
|
||||
|
||||
echo "==> Resolve npm versions"
|
||||
LATEST_VERSION="$(npm view clawdbot version)"
|
||||
EXPECTED_VERSION="$(npm view "clawdbot@${INSTALL_TAG}" version)"
|
||||
if [[ -z "$EXPECTED_VERSION" || "$EXPECTED_VERSION" == "undefined" || "$EXPECTED_VERSION" == "null" ]]; then
|
||||
echo "ERROR: unable to resolve clawdbot@${INSTALL_TAG} version" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -n "$E2E_PREVIOUS_VERSION" ]]; then
|
||||
PREVIOUS_VERSION="$E2E_PREVIOUS_VERSION"
|
||||
else
|
||||
@@ -44,7 +49,7 @@ process.stdout.write(versions.length >= 2 ? versions[versions.length - 2] : vers
|
||||
NODE
|
||||
)"
|
||||
fi
|
||||
echo "latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
|
||||
echo "expected=$EXPECTED_VERSION previous=$PREVIOUS_VERSION"
|
||||
|
||||
if [[ "$SKIP_PREVIOUS" == "1" ]]; then
|
||||
echo "==> Skip preinstall previous (CLAWDBOT_INSTALL_E2E_SKIP_PREVIOUS=1)"
|
||||
@@ -54,13 +59,19 @@ else
|
||||
fi
|
||||
|
||||
echo "==> Run official installer one-liner"
|
||||
curl -fsSL "$INSTALL_URL" | bash
|
||||
if [[ "$INSTALL_TAG" == "beta" ]]; then
|
||||
CLAWDBOT_BETA=1 curl -fsSL "$INSTALL_URL" | bash
|
||||
elif [[ "$INSTALL_TAG" != "latest" ]]; then
|
||||
CLAWDBOT_VERSION="$INSTALL_TAG" curl -fsSL "$INSTALL_URL" | bash
|
||||
else
|
||||
curl -fsSL "$INSTALL_URL" | bash
|
||||
fi
|
||||
|
||||
echo "==> Verify installed version"
|
||||
INSTALLED_VERSION="$(clawdbot --version 2>/dev/null | head -n 1 | tr -d '\r')"
|
||||
echo "installed=$INSTALLED_VERSION expected=$LATEST_VERSION"
|
||||
if [[ "$INSTALLED_VERSION" != "$LATEST_VERSION" ]]; then
|
||||
echo "ERROR: expected clawdbot@$LATEST_VERSION, got clawdbot@$INSTALLED_VERSION" >&2
|
||||
echo "installed=$INSTALLED_VERSION expected=$EXPECTED_VERSION"
|
||||
if [[ "$INSTALLED_VERSION" != "$EXPECTED_VERSION" ]]; then
|
||||
echo "ERROR: expected clawdbot@$EXPECTED_VERSION, got clawdbot@$INSTALLED_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -226,17 +237,20 @@ if (expectProvider && provider && provider !== expectProvider) {
|
||||
NODE
|
||||
}
|
||||
|
||||
extract_first_text() {
|
||||
extract_matching_text() {
|
||||
local path="$1"
|
||||
node - <<'NODE' "$path"
|
||||
local expected="$2"
|
||||
node - <<'NODE' "$path" "$expected"
|
||||
const fs = require("node:fs");
|
||||
const p = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const expected = String(process.argv[3] ?? "");
|
||||
const payloads =
|
||||
Array.isArray(p?.result?.payloads) ? p.result.payloads :
|
||||
Array.isArray(p?.payloads) ? p.payloads :
|
||||
[];
|
||||
const text = payloads.map((x) => String(x?.text ?? "").trim()).filter(Boolean)[0] ?? "";
|
||||
process.stdout.write(text);
|
||||
const texts = payloads.map((x) => String(x?.text ?? "").trim()).filter(Boolean);
|
||||
const match = texts.find((text) => text === expected);
|
||||
process.stdout.write(match ?? texts[0] ?? "");
|
||||
NODE
|
||||
}
|
||||
|
||||
@@ -442,7 +456,7 @@ run_profile() {
|
||||
assert_agent_json_has_text "$TURN1_JSON"
|
||||
assert_agent_json_ok "$TURN1_JSON" "$agent_model_provider"
|
||||
local reply1
|
||||
reply1="$(extract_first_text "$TURN1_JSON" | tr -d '\r\n')"
|
||||
reply1="$(extract_matching_text "$TURN1_JSON" "$PROOF_VALUE" | tr -d '\r\n')"
|
||||
if [[ "$reply1" != "$PROOF_VALUE" ]]; then
|
||||
echo "ERROR: agent did not read proof.txt correctly ($profile): $reply1" >&2
|
||||
exit 1
|
||||
@@ -460,7 +474,7 @@ run_profile() {
|
||||
exit 1
|
||||
fi
|
||||
local reply2
|
||||
reply2="$(extract_first_text "$TURN2_JSON" | tr -d '\r\n')"
|
||||
reply2="$(extract_matching_text "$TURN2_JSON" "$PROOF_VALUE" | tr -d '\r\n')"
|
||||
if [[ "$reply2" != "$PROOF_VALUE" ]]; then
|
||||
echo "ERROR: agent did not read copy.txt correctly ($profile): $reply2" >&2
|
||||
exit 1
|
||||
@@ -486,7 +500,7 @@ run_profile() {
|
||||
exit 1
|
||||
fi
|
||||
local reply4
|
||||
reply4="$(extract_first_text "$TURN4_JSON")"
|
||||
reply4="$(extract_matching_text "$TURN4_JSON" "LEFT=RED RIGHT=GREEN")"
|
||||
if [[ "$reply4" != "LEFT=RED RIGHT=GREEN" ]]; then
|
||||
echo "ERROR: agent reply did not contain expected marker ($profile): $reply4" >&2
|
||||
exit 1
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user