Merge branch 'main' into commands-list-clean
This commit is contained in:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -2,17 +2,31 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Doctor/Daemon: audit supervisor configs, recommend doctor from daemon status, and document user vs system services. (#?) — thanks @steipete
|
||||||
|
- Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
|
||||||
|
- Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos
|
||||||
|
- Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot
|
||||||
|
- Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos
|
||||||
|
- Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123
|
||||||
|
- Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123
|
||||||
|
- Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1
|
||||||
|
- Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess
|
||||||
|
- Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj
|
||||||
|
- macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy
|
||||||
- WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj
|
- WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj
|
||||||
- Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini).
|
- Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini).
|
||||||
- Control UI: logs tab opens at the newest entries (bottom).
|
- Control UI: logs tab opens at the newest entries (bottom).
|
||||||
- Control UI: add Docs link, remove chat composer divider, and add New session button.
|
- Control UI: add Docs link, remove chat composer divider, and add New session button.
|
||||||
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
|
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
|
||||||
|
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
|
||||||
- Agent system prompt: avoid automatic self-updates unless explicitly requested.
|
- Agent system prompt: avoid automatic self-updates unless explicitly requested.
|
||||||
- Onboarding: tighten QuickStart hint copy for configuring later.
|
- Onboarding: tighten QuickStart hint copy for configuring later.
|
||||||
- Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic.
|
- Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic.
|
||||||
- Onboarding: QuickStart jumps straight into provider selection with Telegram preselected when unset.
|
- Onboarding: QuickStart jumps straight into provider selection with Telegram preselected when unset.
|
||||||
- Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
|
- Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
|
||||||
- Daemon runtime: remove Bun from selection options.
|
- Daemon runtime: remove Bun from selection options.
|
||||||
|
- CLI: restore hidden `gateway-daemon` alias for legacy launchd configs.
|
||||||
|
- Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf
|
||||||
|
|
||||||
## 2026.1.8
|
## 2026.1.8
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,75 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum ClawdbotKitResources {
|
public enum ClawdbotKitResources {
|
||||||
public static let bundle: Bundle = .module
|
/// Resource bundle for ClawdbotKit.
|
||||||
|
///
|
||||||
|
/// Locates the SwiftPM-generated resource bundle, checking multiple locations:
|
||||||
|
/// 1. Inside Bundle.main (packaged apps)
|
||||||
|
/// 2. Bundle.module (SwiftPM development/tests)
|
||||||
|
/// 3. Falls back to Bundle.main if not found (resource lookups will return nil)
|
||||||
|
///
|
||||||
|
/// This avoids a fatal crash when Bundle.module can't locate its resources
|
||||||
|
/// in packaged .app bundles where the resource bundle path differs from
|
||||||
|
/// SwiftPM's expectations.
|
||||||
|
public static let bundle: Bundle = locateBundle()
|
||||||
|
|
||||||
|
private static let bundleName = "ClawdbotKit_ClawdbotKit"
|
||||||
|
|
||||||
|
private static func locateBundle() -> Bundle {
|
||||||
|
// 1. Check inside Bundle.main (packaged apps copy resources here)
|
||||||
|
if let mainResourceURL = Bundle.main.resourceURL {
|
||||||
|
let bundleURL = mainResourceURL.appendingPathComponent("\(bundleName).bundle")
|
||||||
|
if let bundle = Bundle(url: bundleURL) {
|
||||||
|
return bundle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check Bundle.main directly for embedded resources
|
||||||
|
if Bundle.main.url(forResource: "tool-display", withExtension: "json") != nil {
|
||||||
|
return Bundle.main
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try Bundle.module (works in SwiftPM development/tests)
|
||||||
|
// Wrap in a function to defer the fatalError until actually called
|
||||||
|
if let moduleBundle = loadModuleBundleSafely() {
|
||||||
|
return moduleBundle
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fallback: return Bundle.main (resource lookups will return nil gracefully)
|
||||||
|
return Bundle.main
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadModuleBundleSafely() -> Bundle? {
|
||||||
|
// Bundle.module is generated by SwiftPM and will fatalError if not found.
|
||||||
|
// We check likely locations manually to avoid the crash.
|
||||||
|
let candidates: [URL?] = [
|
||||||
|
Bundle.main.resourceURL,
|
||||||
|
Bundle.main.bundleURL,
|
||||||
|
Bundle(for: BundleLocator.self).resourceURL,
|
||||||
|
Bundle(for: BundleLocator.self).bundleURL,
|
||||||
|
]
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
guard let baseURL = candidate else { continue }
|
||||||
|
|
||||||
|
// Direct path
|
||||||
|
let directURL = baseURL.appendingPathComponent("\(bundleName).bundle")
|
||||||
|
if let bundle = Bundle(url: directURL) {
|
||||||
|
return bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside Resources/
|
||||||
|
let resourcesURL = baseURL
|
||||||
|
.appendingPathComponent("Resources")
|
||||||
|
.appendingPathComponent("\(bundleName).bundle")
|
||||||
|
if let bundle = Bundle(url: resourcesURL) {
|
||||||
|
return bundle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper class for bundle lookup via Bundle(for:)
|
||||||
|
private final class BundleLocator {}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import ClawdbotKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Suite struct ToolDisplayRegistryTests {
|
||||||
|
@Test func loadsToolDisplayConfigFromBundle() {
|
||||||
|
let url = ClawdbotKitResources.bundle.url(forResource: "tool-display", withExtension: "json")
|
||||||
|
#expect(url != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolvesKnownToolFromConfig() {
|
||||||
|
let summary = ToolDisplayRegistry.resolve(name: "bash", args: nil)
|
||||||
|
#expect(summary.emoji == "🛠️")
|
||||||
|
#expect(summary.title == "Bash")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ cat ~/.clawdbot/clawdbot.json
|
|||||||
- Sandbox image repair when sandboxing is enabled.
|
- Sandbox image repair when sandboxing is enabled.
|
||||||
- Legacy service migration and extra gateway detection.
|
- Legacy service migration and extra gateway detection.
|
||||||
- Gateway runtime checks (service installed but not running; cached launchd label).
|
- Gateway runtime checks (service installed but not running; cached launchd label).
|
||||||
|
- Supervisor config audit (launchd/systemd/schtasks) with optional repair.
|
||||||
- Gateway port collision diagnostics (default `18789`).
|
- Gateway port collision diagnostics (default `18789`).
|
||||||
- Security warnings for open DM policies.
|
- Security warnings for open DM policies.
|
||||||
- systemd linger check on Linux.
|
- systemd linger check on Linux.
|
||||||
@@ -143,17 +144,23 @@ workspace.
|
|||||||
Doctor runs a health check and offers to restart the gateway when it looks
|
Doctor runs a health check and offers to restart the gateway when it looks
|
||||||
unhealthy.
|
unhealthy.
|
||||||
|
|
||||||
### 11) Gateway runtime + port diagnostics
|
### 11) Supervisor config audit + repair
|
||||||
|
Doctor checks the installed supervisor config (launchd/systemd/schtasks) for
|
||||||
|
missing or outdated defaults (e.g., systemd network-online dependencies and
|
||||||
|
restart delay). When it finds a mismatch, it recommends an update and can
|
||||||
|
rewrite the service file/task to the current defaults.
|
||||||
|
|
||||||
|
### 12) Gateway runtime + port diagnostics
|
||||||
Doctor inspects the daemon runtime (PID, last exit status) and warns when the
|
Doctor inspects the daemon runtime (PID, last exit status) and warns when the
|
||||||
service is installed but not actually running. It also checks for port collisions
|
service is installed but not actually running. It also checks for port collisions
|
||||||
on the gateway port (default `18789`) and reports likely causes (gateway already
|
on the gateway port (default `18789`) and reports likely causes (gateway already
|
||||||
running, SSH tunnel).
|
running, SSH tunnel).
|
||||||
|
|
||||||
### 12) Config write + wizard metadata
|
### 13) Config write + wizard metadata
|
||||||
Doctor persists any config changes and stamps wizard metadata to record the
|
Doctor persists any config changes and stamps wizard metadata to record the
|
||||||
doctor run.
|
doctor run.
|
||||||
|
|
||||||
### 13) Workspace tips (backup + memory system)
|
### 14) Workspace tips (backup + memory system)
|
||||||
Doctor suggests a workspace memory system when missing and prints a backup tip
|
Doctor suggests a workspace memory system when missing and prints a backup tip
|
||||||
if the workspace is not already under git.
|
if the workspace is not already under git.
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,14 @@ Bundled mac app:
|
|||||||
- `launchctl` only works if the LaunchAgent is installed; otherwise use `clawdbot daemon install` first.
|
- `launchctl` only works if the LaunchAgent is installed; otherwise use `clawdbot daemon install` first.
|
||||||
|
|
||||||
## Supervision (systemd user unit)
|
## Supervision (systemd user unit)
|
||||||
|
Clawdbot installs a **systemd user service** by default on Linux/WSL2. We
|
||||||
|
recommend user services for single-user machines (simpler env, per-user config).
|
||||||
|
Use a **system service** for multi-user or always-on servers (no lingering
|
||||||
|
required, shared supervision).
|
||||||
|
|
||||||
|
`clawdbot daemon install` writes the user unit. `clawdbot doctor` audits the
|
||||||
|
unit and can update it to match the current recommended defaults.
|
||||||
|
|
||||||
Create `~/.config/systemd/user/clawdbot-gateway.service`:
|
Create `~/.config/systemd/user/clawdbot-gateway.service`:
|
||||||
```
|
```
|
||||||
[Unit]
|
[Unit]
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ clawdbot doctor
|
|||||||
```
|
```
|
||||||
|
|
||||||
## System control (systemd user unit)
|
## System control (systemd user unit)
|
||||||
Full unit example lives in the [Gateway runbook](/gateway). Minimal setup:
|
Clawdbot installs a systemd **user** service by default. Use a **system**
|
||||||
|
service for shared or always-on servers. The full unit example and guidance
|
||||||
|
live in the [Gateway runbook](/gateway).
|
||||||
|
|
||||||
|
Minimal setup:
|
||||||
|
|
||||||
Create `~/.config/systemd/user/clawdbot-gateway.service`:
|
Create `~/.config/systemd/user/clawdbot-gateway.service`:
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,15 @@ if [ -d "$SPARKLE_FRAMEWORK_PRIMARY" ]; then
|
|||||||
chmod -R a+rX "$APP_ROOT/Contents/Frameworks/Sparkle.framework"
|
chmod -R a+rX "$APP_ROOT/Contents/Frameworks/Sparkle.framework"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "📦 Copying Swift 6.2 compatibility libraries"
|
||||||
|
SWIFT_COMPAT_LIB="$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-6.2/macosx/libswiftCompatibilitySpan.dylib"
|
||||||
|
if [ -f "$SWIFT_COMPAT_LIB" ]; then
|
||||||
|
cp "$SWIFT_COMPAT_LIB" "$APP_ROOT/Contents/Frameworks/"
|
||||||
|
chmod +x "$APP_ROOT/Contents/Frameworks/libswiftCompatibilitySpan.dylib"
|
||||||
|
else
|
||||||
|
echo "WARN: Swift compatibility library not found at $SWIFT_COMPAT_LIB (continuing)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
echo "🖼 Copying app icon"
|
echo "🖼 Copying app icon"
|
||||||
cp "$ROOT_DIR/apps/macos/Sources/Clawdbot/Resources/Clawdbot.icns" "$APP_ROOT/Contents/Resources/Clawdbot.icns"
|
cp "$ROOT_DIR/apps/macos/Sources/Clawdbot/Resources/Clawdbot.icns" "$APP_ROOT/Contents/Resources/Clawdbot.icns"
|
||||||
|
|
||||||
@@ -228,6 +237,15 @@ echo "📦 Copying device model resources"
|
|||||||
rm -rf "$APP_ROOT/Contents/Resources/DeviceModels"
|
rm -rf "$APP_ROOT/Contents/Resources/DeviceModels"
|
||||||
cp -R "$ROOT_DIR/apps/macos/Sources/Clawdbot/Resources/DeviceModels" "$APP_ROOT/Contents/Resources/DeviceModels"
|
cp -R "$ROOT_DIR/apps/macos/Sources/Clawdbot/Resources/DeviceModels" "$APP_ROOT/Contents/Resources/DeviceModels"
|
||||||
|
|
||||||
|
echo "📦 Copying ClawdbotKit resources"
|
||||||
|
CLAWDBOTKIT_BUNDLE="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG/ClawdbotKit_ClawdbotKit.bundle"
|
||||||
|
if [ -d "$CLAWDBOTKIT_BUNDLE" ]; then
|
||||||
|
rm -rf "$APP_ROOT/Contents/Resources/ClawdbotKit_ClawdbotKit.bundle"
|
||||||
|
cp -R "$CLAWDBOTKIT_BUNDLE" "$APP_ROOT/Contents/Resources/ClawdbotKit_ClawdbotKit.bundle"
|
||||||
|
else
|
||||||
|
echo "WARN: ClawdbotKit resource bundle not found at $CLAWDBOTKIT_BUNDLE (continuing)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
RELAY_DIR="$APP_ROOT/Contents/Resources/Relay"
|
RELAY_DIR="$APP_ROOT/Contents/Resources/Relay"
|
||||||
|
|
||||||
if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then
|
if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
toolNames: ["gateway", "bash"],
|
toolNames: ["gateway", "bash"],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prompt).toContain("## ClaudeBot Self-Update");
|
expect(prompt).toContain("## Clawdbot Self-Update");
|
||||||
expect(prompt).toContain("config.apply");
|
expect(prompt).toContain("config.apply");
|
||||||
expect(prompt).toContain("update.run");
|
expect(prompt).toContain("update.run");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
nodes: "List/describe/notify/camera/screen on paired nodes",
|
nodes: "List/describe/notify/camera/screen on paired nodes",
|
||||||
cron: "Manage cron jobs and wake events",
|
cron: "Manage cron jobs and wake events",
|
||||||
gateway:
|
gateway:
|
||||||
"Restart, apply config, or run updates on the running ClaudeBot process",
|
"Restart, apply config, or run updates on the running Clawdbot process",
|
||||||
agents_list: "List agent ids allowed for sessions_spawn",
|
agents_list: "List agent ids allowed for sessions_spawn",
|
||||||
sessions_list: "List other sessions (incl. sub-agents) with filters/last",
|
sessions_list: "List other sessions (incl. sub-agents) with filters/last",
|
||||||
sessions_history: "Fetch history for another session/sub-agent",
|
sessions_history: "Fetch history for another session/sub-agent",
|
||||||
@@ -129,7 +129,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
const runtimeInfo = params.runtimeInfo;
|
const runtimeInfo = params.runtimeInfo;
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
"You are a personal assistant running inside ClaudeBot.",
|
"You are a personal assistant running inside Clawdbot.",
|
||||||
"",
|
"",
|
||||||
"## Tooling",
|
"## Tooling",
|
||||||
"Tool availability (filtered by policy):",
|
"Tool availability (filtered by policy):",
|
||||||
@@ -157,13 +157,13 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
"## Skills",
|
"## Skills",
|
||||||
`Skills provide task-specific instructions. Use \`read\` to load from ${params.workspaceDir}/skills/<name>/SKILL.md when needed.`,
|
`Skills provide task-specific instructions. Use \`read\` to load from ${params.workspaceDir}/skills/<name>/SKILL.md when needed.`,
|
||||||
"",
|
"",
|
||||||
hasGateway ? "## ClaudeBot Self-Update" : "",
|
hasGateway ? "## Clawdbot Self-Update" : "",
|
||||||
hasGateway
|
hasGateway
|
||||||
? [
|
? [
|
||||||
"Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.",
|
"Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.",
|
||||||
"Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.",
|
"Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.",
|
||||||
"Actions: config.get, config.schema, config.apply (validate + write full config, then restart), update.run (update deps or git, then restart).",
|
"Actions: config.get, config.schema, config.apply (validate + write full config, then restart), update.run (update deps or git, then restart).",
|
||||||
"After restart, ClaudeBot pings the last active session automatically.",
|
"After restart, Clawdbot pings the last active session automatically.",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
: "",
|
: "",
|
||||||
hasGateway ? "" : "",
|
hasGateway ? "" : "",
|
||||||
@@ -212,7 +212,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
ownerLine ?? "",
|
ownerLine ?? "",
|
||||||
ownerLine ? "" : "",
|
ownerLine ? "" : "",
|
||||||
"## Workspace Files (injected)",
|
"## Workspace Files (injected)",
|
||||||
"These user-editable files are loaded by ClaudeBot and included below in Project Context.",
|
"These user-editable files are loaded by Clawdbot and included below in Project Context.",
|
||||||
"",
|
"",
|
||||||
userTimezone || userTime
|
userTimezone || userTime
|
||||||
? `Time: assume UTC unless stated. User TZ=${userTimezone ?? "unknown"}. Current user time (converted)=${userTime ?? "unknown"}.`
|
? `Time: assume UTC unless stated. User TZ=${userTimezone ?? "unknown"}. Current user time (converted)=${userTime ?? "unknown"}.`
|
||||||
@@ -251,7 +251,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
heartbeatPromptLine,
|
heartbeatPromptLine,
|
||||||
"If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:",
|
"If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:",
|
||||||
"HEARTBEAT_OK",
|
"HEARTBEAT_OK",
|
||||||
'ClaudeBot treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).',
|
'Clawdbot treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).',
|
||||||
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
|
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
|
||||||
"",
|
"",
|
||||||
"## Runtime",
|
"## Runtime",
|
||||||
|
|||||||
@@ -16,12 +16,20 @@ describe("cron tool", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
[
|
[
|
||||||
"update",
|
"update",
|
||||||
{ action: "update", id: "job-1", patch: { foo: "bar" } },
|
{ action: "update", jobId: "job-1", patch: { foo: "bar" } },
|
||||||
{ id: "job-1", patch: { foo: "bar" } },
|
{ id: "job-1", patch: { foo: "bar" } },
|
||||||
],
|
],
|
||||||
["remove", { action: "remove", id: "job-1" }, { id: "job-1" }],
|
[
|
||||||
["run", { action: "run", id: "job-1" }, { id: "job-1" }],
|
"update",
|
||||||
["runs", { action: "runs", id: "job-1" }, { id: "job-1" }],
|
{ action: "update", id: "job-2", patch: { foo: "bar" } },
|
||||||
|
{ id: "job-2", patch: { foo: "bar" } },
|
||||||
|
],
|
||||||
|
["remove", { action: "remove", jobId: "job-1" }, { id: "job-1" }],
|
||||||
|
["remove", { action: "remove", id: "job-2" }, { id: "job-2" }],
|
||||||
|
["run", { action: "run", jobId: "job-1" }, { id: "job-1" }],
|
||||||
|
["run", { action: "run", id: "job-2" }, { id: "job-2" }],
|
||||||
|
["runs", { action: "runs", jobId: "job-1" }, { id: "job-1" }],
|
||||||
|
["runs", { action: "runs", id: "job-2" }, { id: "job-2" }],
|
||||||
])("%s sends id to gateway", async (action, args, expectedParams) => {
|
])("%s sends id to gateway", async (action, args, expectedParams) => {
|
||||||
const tool = createCronTool();
|
const tool = createCronTool();
|
||||||
await tool.execute("call1", args);
|
await tool.execute("call1", args);
|
||||||
@@ -35,6 +43,20 @@ describe("cron tool", () => {
|
|||||||
expect(call.params).toEqual(expectedParams);
|
expect(call.params).toEqual(expectedParams);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers jobId over id when both are provided", async () => {
|
||||||
|
const tool = createCronTool();
|
||||||
|
await tool.execute("call1", {
|
||||||
|
action: "run",
|
||||||
|
jobId: "job-primary",
|
||||||
|
id: "job-legacy",
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||||
|
params?: unknown;
|
||||||
|
};
|
||||||
|
expect(call?.params).toEqual({ id: "job-primary" });
|
||||||
|
});
|
||||||
|
|
||||||
it("normalizes cron.add job payloads", async () => {
|
it("normalizes cron.add job payloads", async () => {
|
||||||
const tool = createCronTool();
|
const tool = createCronTool();
|
||||||
await tool.execute("call2", {
|
await tool.execute("call2", {
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ const CronToolSchema = Type.Union([
|
|||||||
gatewayUrl: Type.Optional(Type.String()),
|
gatewayUrl: Type.Optional(Type.String()),
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
gatewayToken: Type.Optional(Type.String()),
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
timeoutMs: Type.Optional(Type.Number()),
|
||||||
id: Type.String(),
|
jobId: Type.Optional(Type.String()),
|
||||||
|
id: Type.Optional(Type.String()),
|
||||||
patch: Type.Object({}, { additionalProperties: true }),
|
patch: Type.Object({}, { additionalProperties: true }),
|
||||||
}),
|
}),
|
||||||
Type.Object({
|
Type.Object({
|
||||||
@@ -55,21 +56,24 @@ const CronToolSchema = Type.Union([
|
|||||||
gatewayUrl: Type.Optional(Type.String()),
|
gatewayUrl: Type.Optional(Type.String()),
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
gatewayToken: Type.Optional(Type.String()),
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
timeoutMs: Type.Optional(Type.Number()),
|
||||||
id: Type.String(),
|
jobId: Type.Optional(Type.String()),
|
||||||
|
id: Type.Optional(Type.String()),
|
||||||
}),
|
}),
|
||||||
Type.Object({
|
Type.Object({
|
||||||
action: Type.Literal("run"),
|
action: Type.Literal("run"),
|
||||||
gatewayUrl: Type.Optional(Type.String()),
|
gatewayUrl: Type.Optional(Type.String()),
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
gatewayToken: Type.Optional(Type.String()),
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
timeoutMs: Type.Optional(Type.Number()),
|
||||||
id: Type.String(),
|
jobId: Type.Optional(Type.String()),
|
||||||
|
id: Type.Optional(Type.String()),
|
||||||
}),
|
}),
|
||||||
Type.Object({
|
Type.Object({
|
||||||
action: Type.Literal("runs"),
|
action: Type.Literal("runs"),
|
||||||
gatewayUrl: Type.Optional(Type.String()),
|
gatewayUrl: Type.Optional(Type.String()),
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
gatewayToken: Type.Optional(Type.String()),
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
timeoutMs: Type.Optional(Type.Number()),
|
||||||
id: Type.String(),
|
jobId: Type.Optional(Type.String()),
|
||||||
|
id: Type.Optional(Type.String()),
|
||||||
}),
|
}),
|
||||||
Type.Object({
|
Type.Object({
|
||||||
action: Type.Literal("wake"),
|
action: Type.Literal("wake"),
|
||||||
@@ -88,7 +92,7 @@ export function createCronTool(): AnyAgentTool {
|
|||||||
label: "Cron",
|
label: "Cron",
|
||||||
name: "cron",
|
name: "cron",
|
||||||
description:
|
description:
|
||||||
"Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events.",
|
"Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use `jobId` as the canonical identifier; `id` is accepted for compatibility.",
|
||||||
parameters: CronToolSchema,
|
parameters: CronToolSchema,
|
||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args) => {
|
||||||
const params = args as Record<string, unknown>;
|
const params = args as Record<string, unknown>;
|
||||||
@@ -121,7 +125,13 @@ export function createCronTool(): AnyAgentTool {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "update": {
|
case "update": {
|
||||||
const id = readStringParam(params, "id", { required: true });
|
const id =
|
||||||
|
readStringParam(params, "jobId") ?? readStringParam(params, "id");
|
||||||
|
if (!id) {
|
||||||
|
throw new Error(
|
||||||
|
"jobId required (id accepted for backward compatibility)",
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!params.patch || typeof params.patch !== "object") {
|
if (!params.patch || typeof params.patch !== "object") {
|
||||||
throw new Error("patch required");
|
throw new Error("patch required");
|
||||||
}
|
}
|
||||||
@@ -134,19 +144,37 @@ export function createCronTool(): AnyAgentTool {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "remove": {
|
case "remove": {
|
||||||
const id = readStringParam(params, "id", { required: true });
|
const id =
|
||||||
|
readStringParam(params, "jobId") ?? readStringParam(params, "id");
|
||||||
|
if (!id) {
|
||||||
|
throw new Error(
|
||||||
|
"jobId required (id accepted for backward compatibility)",
|
||||||
|
);
|
||||||
|
}
|
||||||
return jsonResult(
|
return jsonResult(
|
||||||
await callGatewayTool("cron.remove", gatewayOpts, { id }),
|
await callGatewayTool("cron.remove", gatewayOpts, { id }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "run": {
|
case "run": {
|
||||||
const id = readStringParam(params, "id", { required: true });
|
const id =
|
||||||
|
readStringParam(params, "jobId") ?? readStringParam(params, "id");
|
||||||
|
if (!id) {
|
||||||
|
throw new Error(
|
||||||
|
"jobId required (id accepted for backward compatibility)",
|
||||||
|
);
|
||||||
|
}
|
||||||
return jsonResult(
|
return jsonResult(
|
||||||
await callGatewayTool("cron.run", gatewayOpts, { id }),
|
await callGatewayTool("cron.run", gatewayOpts, { id }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "runs": {
|
case "runs": {
|
||||||
const id = readStringParam(params, "id", { required: true });
|
const id =
|
||||||
|
readStringParam(params, "jobId") ?? readStringParam(params, "id");
|
||||||
|
if (!id) {
|
||||||
|
throw new Error(
|
||||||
|
"jobId required (id accepted for backward compatibility)",
|
||||||
|
);
|
||||||
|
}
|
||||||
return jsonResult(
|
return jsonResult(
|
||||||
await callGatewayTool("cron.runs", gatewayOpts, { id }),
|
await callGatewayTool("cron.runs", gatewayOpts, { id }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ describe("chunkText", () => {
|
|||||||
const chunks = chunkText(text, 10);
|
const chunks = chunkText(text, 10);
|
||||||
expect(chunks).toEqual(["Supercalif", "ragilistic", "expialidoc", "ious"]);
|
expect(chunks).toEqual(["Supercalif", "ragilistic", "expialidoc", "ious"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps parenthetical phrases together", () => {
|
||||||
|
const text = "Heads up now (Though now I'm curious)ok";
|
||||||
|
const chunks = chunkText(text, 35);
|
||||||
|
expect(chunks).toEqual(["Heads up now", "(Though now I'm curious)ok"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveTextChunkLimit", () => {
|
describe("resolveTextChunkLimit", () => {
|
||||||
@@ -184,4 +190,29 @@ describe("chunkMarkdownText", () => {
|
|||||||
expect(nonFenceLines.join("\n").trim()).not.toBe("");
|
expect(nonFenceLines.join("\n").trim()).not.toBe("");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps parenthetical phrases together", () => {
|
||||||
|
const text = "Heads up now (Though now I'm curious)ok";
|
||||||
|
const chunks = chunkMarkdownText(text, 35);
|
||||||
|
expect(chunks).toEqual(["Heads up now", "(Though now I'm curious)ok"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles nested parentheses", () => {
|
||||||
|
const text = "Hello (outer (inner) end) world";
|
||||||
|
const chunks = chunkMarkdownText(text, 26);
|
||||||
|
expect(chunks).toEqual(["Hello (outer (inner) end)", "world"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hard-breaks when a parenthetical exceeds the limit", () => {
|
||||||
|
const text = `(${"a".repeat(80)})`;
|
||||||
|
const chunks = chunkMarkdownText(text, 20);
|
||||||
|
expect(chunks[0]?.length).toBe(20);
|
||||||
|
expect(chunks.join("")).toBe(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unmatched closing parentheses", () => {
|
||||||
|
const text = "Hello) world (ok)";
|
||||||
|
const chunks = chunkMarkdownText(text, 12);
|
||||||
|
expect(chunks).toEqual(["Hello)", "world (ok)"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,18 +90,27 @@ export function chunkText(text: string, limit: number): string[] {
|
|||||||
while (remaining.length > limit) {
|
while (remaining.length > limit) {
|
||||||
const window = remaining.slice(0, limit);
|
const window = remaining.slice(0, limit);
|
||||||
|
|
||||||
// 1) Prefer a newline break inside the window.
|
// 1) Prefer a newline break inside the window (outside parentheses).
|
||||||
let breakIdx = window.lastIndexOf("\n");
|
let lastNewline = -1;
|
||||||
|
let lastWhitespace = -1;
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = 0; i < window.length; i++) {
|
||||||
|
const char = window[i];
|
||||||
|
if (char === "(") {
|
||||||
|
depth += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === ")" && depth > 0) {
|
||||||
|
depth -= 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (depth !== 0) continue;
|
||||||
|
if (char === "\n") lastNewline = i;
|
||||||
|
else if (/\s/.test(char)) lastWhitespace = i;
|
||||||
|
}
|
||||||
|
|
||||||
// 2) Otherwise prefer the last whitespace (word boundary) inside the window.
|
// 2) Otherwise prefer the last whitespace (word boundary) inside the window.
|
||||||
if (breakIdx <= 0) {
|
let breakIdx = lastNewline > 0 ? lastNewline : lastWhitespace;
|
||||||
for (let i = window.length - 1; i >= 0; i--) {
|
|
||||||
if (/\s/.test(window[i])) {
|
|
||||||
breakIdx = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Fallback: hard break exactly at the limit.
|
// 3) Fallback: hard break exactly at the limit.
|
||||||
if (breakIdx <= 0) breakIdx = limit;
|
if (breakIdx <= 0) breakIdx = limit;
|
||||||
@@ -234,15 +243,27 @@ function pickSafeBreakIndex(
|
|||||||
window: string,
|
window: string,
|
||||||
spans: ReturnType<typeof parseFenceSpans>,
|
spans: ReturnType<typeof parseFenceSpans>,
|
||||||
): number {
|
): number {
|
||||||
let newlineIdx = window.lastIndexOf("\n");
|
let lastNewline = -1;
|
||||||
while (newlineIdx > 0) {
|
let lastWhitespace = -1;
|
||||||
if (isSafeFenceBreak(spans, newlineIdx)) return newlineIdx;
|
let depth = 0;
|
||||||
newlineIdx = window.lastIndexOf("\n", newlineIdx - 1);
|
|
||||||
}
|
for (let i = 0; i < window.length; i++) {
|
||||||
|
if (!isSafeFenceBreak(spans, i)) continue;
|
||||||
for (let i = window.length - 1; i > 0; i--) {
|
const char = window[i];
|
||||||
if (/\s/.test(window[i]) && isSafeFenceBreak(spans, i)) return i;
|
if (char === "(") {
|
||||||
|
depth += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === ")" && depth > 0) {
|
||||||
|
depth -= 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (depth !== 0) continue;
|
||||||
|
if (char === "\n") lastNewline = i;
|
||||||
|
else if (/\s/.test(char)) lastWhitespace = i;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lastNewline > 0) return lastNewline;
|
||||||
|
if (lastWhitespace > 0) return lastWhitespace;
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,61 @@ describe("block streaming", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves block reply ordering when typing start is slow", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
let releaseTyping: (() => void) | undefined;
|
||||||
|
const typingGate = new Promise<void>((resolve) => {
|
||||||
|
releaseTyping = resolve;
|
||||||
|
});
|
||||||
|
const onReplyStart = vi.fn(() => typingGate);
|
||||||
|
const seen: string[] = [];
|
||||||
|
const onBlockReply = vi.fn(async (payload) => {
|
||||||
|
seen.push(payload.text ?? "");
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => {
|
||||||
|
void params.onBlockReply?.({ text: "first" });
|
||||||
|
void params.onBlockReply?.({ text: "second" });
|
||||||
|
return {
|
||||||
|
payloads: [{ text: "first" }, { text: "second" }],
|
||||||
|
meta: {
|
||||||
|
durationMs: 5,
|
||||||
|
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const replyPromise = getReplyFromConfig(
|
||||||
|
{
|
||||||
|
Body: "ping",
|
||||||
|
From: "+1004",
|
||||||
|
To: "+2000",
|
||||||
|
MessageSid: "msg-125",
|
||||||
|
Provider: "telegram",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onReplyStart,
|
||||||
|
onBlockReply,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
model: "anthropic/claude-opus-4-5",
|
||||||
|
workspace: path.join(home, "clawd"),
|
||||||
|
},
|
||||||
|
telegram: { allowFrom: ["*"] },
|
||||||
|
session: { store: path.join(home, "sessions.json") },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForCalls(() => onReplyStart.mock.calls.length, 1);
|
||||||
|
releaseTyping?.();
|
||||||
|
|
||||||
|
const res = await replyPromise;
|
||||||
|
expect(res).toBeUndefined();
|
||||||
|
expect(seen).toEqual(["first", "second"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("drops final payloads when block replies streamed", async () => {
|
it("drops final payloads when block replies streamed", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const onBlockReply = vi.fn().mockResolvedValue(undefined);
|
const onBlockReply = vi.fn().mockResolvedValue(undefined);
|
||||||
@@ -143,4 +198,59 @@ describe("block streaming", () => {
|
|||||||
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to final payloads when block reply send times out", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
let sawAbort = false;
|
||||||
|
const onBlockReply = vi.fn((_, context) => {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
context?.abortSignal?.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
sawAbort = true;
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => {
|
||||||
|
void params.onBlockReply?.({ text: "streamed" });
|
||||||
|
return {
|
||||||
|
payloads: [{ text: "final" }],
|
||||||
|
meta: {
|
||||||
|
durationMs: 5,
|
||||||
|
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const replyPromise = getReplyFromConfig(
|
||||||
|
{
|
||||||
|
Body: "ping",
|
||||||
|
From: "+1004",
|
||||||
|
To: "+2000",
|
||||||
|
MessageSid: "msg-126",
|
||||||
|
Provider: "telegram",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onBlockReply,
|
||||||
|
blockReplyTimeoutMs: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
model: "anthropic/claude-opus-4-5",
|
||||||
|
workspace: path.join(home, "clawd"),
|
||||||
|
},
|
||||||
|
telegram: { allowFrom: ["*"] },
|
||||||
|
session: { store: path.join(home, "sessions.json") },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await replyPromise;
|
||||||
|
expect(res).toMatchObject({ text: "final" });
|
||||||
|
expect(sawAbort).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import type { TypingController } from "./typing.js";
|
|||||||
import { createTypingSignaler } from "./typing-mode.js";
|
import { createTypingSignaler } from "./typing-mode.js";
|
||||||
|
|
||||||
const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i;
|
const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i;
|
||||||
|
const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
const isBunFetchSocketError = (message?: string) =>
|
const isBunFetchSocketError = (message?: string) =>
|
||||||
Boolean(message && BUN_FETCH_SOCKET_ERROR_RE.test(message));
|
Boolean(message && BUN_FETCH_SOCKET_ERROR_RE.test(message));
|
||||||
@@ -61,6 +62,23 @@ const formatBunFetchSocketError = (message: string) => {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const withTimeout = async <T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
timeoutError: Error,
|
||||||
|
): Promise<T> => {
|
||||||
|
if (!timeoutMs || timeoutMs <= 0) return promise;
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(timeoutError), timeoutMs);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await Promise.race([promise, timeoutPromise]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export async function runReplyAgent(params: {
|
export async function runReplyAgent(params: {
|
||||||
commandBody: string;
|
commandBody: string;
|
||||||
followupRun: FollowupRun;
|
followupRun: FollowupRun;
|
||||||
@@ -144,7 +162,12 @@ export async function runReplyAgent(params: {
|
|||||||
const pendingStreamedPayloadKeys = new Set<string>();
|
const pendingStreamedPayloadKeys = new Set<string>();
|
||||||
const pendingBlockTasks = new Set<Promise<void>>();
|
const pendingBlockTasks = new Set<Promise<void>>();
|
||||||
const pendingToolTasks = new Set<Promise<void>>();
|
const pendingToolTasks = new Set<Promise<void>>();
|
||||||
|
let blockReplyChain: Promise<void> = Promise.resolve();
|
||||||
|
let blockReplyAborted = false;
|
||||||
|
let didLogBlockReplyAbort = false;
|
||||||
let didStreamBlockReply = false;
|
let didStreamBlockReply = false;
|
||||||
|
const blockReplyTimeoutMs =
|
||||||
|
opts?.blockReplyTimeoutMs ?? BLOCK_REPLY_SEND_TIMEOUT_MS;
|
||||||
const buildPayloadKey = (payload: ReplyPayload) => {
|
const buildPayloadKey = (payload: ReplyPayload) => {
|
||||||
const text = payload.text?.trim() ?? "";
|
const text = payload.text?.trim() ?? "";
|
||||||
const mediaList = payload.mediaUrls?.length
|
const mediaList = payload.mediaUrls?.length
|
||||||
@@ -367,16 +390,49 @@ export async function runReplyAgent(params: {
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (blockReplyAborted) return;
|
||||||
pendingStreamedPayloadKeys.add(payloadKey);
|
pendingStreamedPayloadKeys.add(payloadKey);
|
||||||
const task = (async () => {
|
void typingSignals
|
||||||
await typingSignals.signalTextDelta(taggedPayload.text);
|
.signalTextDelta(taggedPayload.text)
|
||||||
await opts.onBlockReply?.(blockPayload);
|
.catch((err) => {
|
||||||
})()
|
logVerbose(
|
||||||
.then(() => {
|
`block reply typing signal failed: ${String(err)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const timeoutError = new Error(
|
||||||
|
`block reply delivery timed out after ${blockReplyTimeoutMs}ms`,
|
||||||
|
);
|
||||||
|
const abortController = new AbortController();
|
||||||
|
blockReplyChain = blockReplyChain
|
||||||
|
.then(async () => {
|
||||||
|
if (blockReplyAborted) return false;
|
||||||
|
await withTimeout(
|
||||||
|
opts.onBlockReply?.(blockPayload, {
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
timeoutMs: blockReplyTimeoutMs,
|
||||||
|
}) ?? Promise.resolve(),
|
||||||
|
blockReplyTimeoutMs,
|
||||||
|
timeoutError,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.then((didSend) => {
|
||||||
|
if (!didSend) return;
|
||||||
streamedPayloadKeys.add(payloadKey);
|
streamedPayloadKeys.add(payloadKey);
|
||||||
didStreamBlockReply = true;
|
didStreamBlockReply = true;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
if (err === timeoutError) {
|
||||||
|
abortController.abort();
|
||||||
|
blockReplyAborted = true;
|
||||||
|
if (!didLogBlockReplyAbort) {
|
||||||
|
didLogBlockReplyAbort = true;
|
||||||
|
logVerbose(
|
||||||
|
`block reply delivery timed out after ${blockReplyTimeoutMs}ms; skipping remaining block replies to preserve ordering`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`block reply delivery failed: ${String(err)}`,
|
`block reply delivery failed: ${String(err)}`,
|
||||||
);
|
);
|
||||||
@@ -384,6 +440,7 @@ export async function runReplyAgent(params: {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
pendingStreamedPayloadKeys.delete(payloadKey);
|
pendingStreamedPayloadKeys.delete(payloadKey);
|
||||||
});
|
});
|
||||||
|
const task = blockReplyChain;
|
||||||
pendingBlockTasks.add(task);
|
pendingBlockTasks.add(task);
|
||||||
void task.finally(() => pendingBlockTasks.delete(task));
|
void task.finally(() => pendingBlockTasks.delete(task));
|
||||||
}
|
}
|
||||||
@@ -546,10 +603,10 @@ export async function runReplyAgent(params: {
|
|||||||
})
|
})
|
||||||
.filter(isRenderablePayload);
|
.filter(isRenderablePayload);
|
||||||
|
|
||||||
// Drop final payloads if block streaming is enabled and we already streamed
|
// Drop final payloads only when block streaming succeeded end-to-end.
|
||||||
// block replies. Tool-sent duplicates are filtered below.
|
// If streaming aborted (e.g., timeout), fall back to final payloads.
|
||||||
const shouldDropFinalPayloads =
|
const shouldDropFinalPayloads =
|
||||||
blockStreamingEnabled && didStreamBlockReply;
|
blockStreamingEnabled && didStreamBlockReply && !blockReplyAborted;
|
||||||
const messagingToolSentTexts = runResult.messagingToolSentTexts ?? [];
|
const messagingToolSentTexts = runResult.messagingToolSentTexts ?? [];
|
||||||
const messagingToolSentTargets = runResult.messagingToolSentTargets ?? [];
|
const messagingToolSentTargets = runResult.messagingToolSentTargets ?? [];
|
||||||
const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({
|
const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({
|
||||||
|
|||||||
@@ -456,7 +456,7 @@ export async function handleCommands(params: {
|
|||||||
...cfg.agent,
|
...cfg.agent,
|
||||||
model: {
|
model: {
|
||||||
...cfg.agent?.model,
|
...cfg.agent?.model,
|
||||||
primary: model,
|
primary: `${provider}/${model}`,
|
||||||
},
|
},
|
||||||
contextTokens,
|
contextTokens,
|
||||||
thinkingDefault: cfg.agent?.thinkingDefault,
|
thinkingDefault: cfg.agent?.thinkingDefault,
|
||||||
|
|||||||
@@ -41,10 +41,14 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
* Note: Only called when shouldRouteToOriginating is true, so
|
* Note: Only called when shouldRouteToOriginating is true, so
|
||||||
* originatingChannel and originatingTo are guaranteed to be defined.
|
* originatingChannel and originatingTo are guaranteed to be defined.
|
||||||
*/
|
*/
|
||||||
const sendPayloadAsync = async (payload: ReplyPayload): Promise<void> => {
|
const sendPayloadAsync = async (
|
||||||
|
payload: ReplyPayload,
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<void> => {
|
||||||
// TypeScript doesn't narrow these from the shouldRouteToOriginating check,
|
// TypeScript doesn't narrow these from the shouldRouteToOriginating check,
|
||||||
// but they're guaranteed non-null when this function is called.
|
// but they're guaranteed non-null when this function is called.
|
||||||
if (!originatingChannel || !originatingTo) return;
|
if (!originatingChannel || !originatingTo) return;
|
||||||
|
if (abortSignal?.aborted) return;
|
||||||
const result = await routeReply({
|
const result = await routeReply({
|
||||||
payload,
|
payload,
|
||||||
channel: originatingChannel,
|
channel: originatingChannel,
|
||||||
@@ -52,6 +56,7 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
accountId: ctx.AccountId,
|
accountId: ctx.AccountId,
|
||||||
threadId: ctx.MessageThreadId,
|
threadId: ctx.MessageThreadId,
|
||||||
cfg,
|
cfg,
|
||||||
|
abortSignal,
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
@@ -73,10 +78,10 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
dispatcher.sendToolResult(payload);
|
dispatcher.sendToolResult(payload);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onBlockReply: (payload: ReplyPayload) => {
|
onBlockReply: (payload: ReplyPayload, context) => {
|
||||||
if (shouldRouteToOriginating) {
|
if (shouldRouteToOriginating) {
|
||||||
// Fire-and-forget for streaming block replies when routing.
|
// Await routed sends so upstream can enforce ordering/timeouts.
|
||||||
void sendPayloadAsync(payload);
|
return sendPayloadAsync(payload, context?.abortSignal);
|
||||||
} else {
|
} else {
|
||||||
// Synchronous dispatch to preserve callback timing.
|
// Synchronous dispatch to preserve callback timing.
|
||||||
dispatcher.sendBlockReply(payload);
|
dispatcher.sendBlockReply(payload);
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ vi.mock("../../web/outbound.js", () => ({
|
|||||||
const { routeReply } = await import("./route-reply.js");
|
const { routeReply } = await import("./route-reply.js");
|
||||||
|
|
||||||
describe("routeReply", () => {
|
describe("routeReply", () => {
|
||||||
|
it("skips sends when abort signal is already aborted", async () => {
|
||||||
|
mocks.sendMessageSlack.mockClear();
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
const res = await routeReply({
|
||||||
|
payload: { text: "hi" },
|
||||||
|
channel: "slack",
|
||||||
|
to: "channel:C123",
|
||||||
|
cfg: {} as never,
|
||||||
|
abortSignal: controller.signal,
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.error).toContain("aborted");
|
||||||
|
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("no-ops on empty payload", async () => {
|
it("no-ops on empty payload", async () => {
|
||||||
mocks.sendMessageSlack.mockClear();
|
mocks.sendMessageSlack.mockClear();
|
||||||
const res = await routeReply({
|
const res = await routeReply({
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export type RouteReplyParams = {
|
|||||||
threadId?: number;
|
threadId?: number;
|
||||||
/** Config for provider-specific settings. */
|
/** Config for provider-specific settings. */
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
|
/** Optional abort signal for cooperative cancellation. */
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RouteReplyResult = {
|
export type RouteReplyResult = {
|
||||||
@@ -52,7 +54,7 @@ export type RouteReplyResult = {
|
|||||||
export async function routeReply(
|
export async function routeReply(
|
||||||
params: RouteReplyParams,
|
params: RouteReplyParams,
|
||||||
): Promise<RouteReplyResult> {
|
): Promise<RouteReplyResult> {
|
||||||
const { payload, channel, to, accountId, threadId } = params;
|
const { payload, channel, to, accountId, threadId, abortSignal } = params;
|
||||||
|
|
||||||
// Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts`
|
// Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts`
|
||||||
const text = payload.text ?? "";
|
const text = payload.text ?? "";
|
||||||
@@ -72,6 +74,9 @@ export async function routeReply(
|
|||||||
text: string;
|
text: string;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
}): Promise<RouteReplyResult> => {
|
}): Promise<RouteReplyResult> => {
|
||||||
|
if (abortSignal?.aborted) {
|
||||||
|
return { ok: false, error: "Reply routing aborted" };
|
||||||
|
}
|
||||||
const { text, mediaUrl } = params;
|
const { text, mediaUrl } = params;
|
||||||
switch (channel) {
|
switch (channel) {
|
||||||
case "telegram": {
|
case "telegram": {
|
||||||
@@ -148,12 +153,18 @@ export async function routeReply(
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (abortSignal?.aborted) {
|
||||||
|
return { ok: false, error: "Reply routing aborted" };
|
||||||
|
}
|
||||||
if (mediaUrls.length === 0) {
|
if (mediaUrls.length === 0) {
|
||||||
return await sendOne({ text });
|
return await sendOne({ text });
|
||||||
}
|
}
|
||||||
|
|
||||||
let last: RouteReplyResult | undefined;
|
let last: RouteReplyResult | undefined;
|
||||||
for (let i = 0; i < mediaUrls.length; i++) {
|
for (let i = 0; i < mediaUrls.length; i++) {
|
||||||
|
if (abortSignal?.aborted) {
|
||||||
|
return { ok: false, error: "Reply routing aborted" };
|
||||||
|
}
|
||||||
const mediaUrl = mediaUrls[i];
|
const mediaUrl = mediaUrls[i];
|
||||||
const caption = i === 0 ? text : "";
|
const caption = i === 0 ? text : "";
|
||||||
last = await sendOne({ text: caption, mediaUrl });
|
last = await sendOne({ text: caption, mediaUrl });
|
||||||
|
|||||||
@@ -102,6 +102,18 @@ describe("buildStatusMessage", () => {
|
|||||||
expect(text).toContain("🧠 Model: openai/gpt-4.1-mini");
|
expect(text).toContain("🧠 Model: openai/gpt-4.1-mini");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps provider prefix from configured model", () => {
|
||||||
|
const text = buildStatusMessage({
|
||||||
|
agent: {
|
||||||
|
model: "google-antigravity/claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
sessionScope: "per-sender",
|
||||||
|
queue: { mode: "collect", depth: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("🧠 Model: google-antigravity/claude-sonnet-4-5");
|
||||||
|
});
|
||||||
|
|
||||||
it("handles missing agent config gracefully", () => {
|
it("handles missing agent config gracefully", () => {
|
||||||
const text = buildStatusMessage({
|
const text = buildStatusMessage({
|
||||||
agent: {},
|
agent: {},
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import type { TypingController } from "./reply/typing.js";
|
import type { TypingController } from "./reply/typing.js";
|
||||||
|
|
||||||
|
export type BlockReplyContext = {
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type GetReplyOptions = {
|
export type GetReplyOptions = {
|
||||||
onReplyStart?: () => Promise<void> | void;
|
onReplyStart?: () => Promise<void> | void;
|
||||||
onTypingController?: (typing: TypingController) => void;
|
onTypingController?: (typing: TypingController) => void;
|
||||||
isHeartbeat?: boolean;
|
isHeartbeat?: boolean;
|
||||||
onPartialReply?: (payload: ReplyPayload) => Promise<void> | void;
|
onPartialReply?: (payload: ReplyPayload) => Promise<void> | void;
|
||||||
onReasoningStream?: (payload: ReplyPayload) => Promise<void> | void;
|
onReasoningStream?: (payload: ReplyPayload) => Promise<void> | void;
|
||||||
onBlockReply?: (payload: ReplyPayload) => Promise<void> | void;
|
onBlockReply?: (
|
||||||
|
payload: ReplyPayload,
|
||||||
|
context?: BlockReplyContext,
|
||||||
|
) => Promise<void> | void;
|
||||||
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
|
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
|
||||||
disableBlockStreaming?: boolean;
|
disableBlockStreaming?: boolean;
|
||||||
|
/** Timeout for block reply delivery (ms). */
|
||||||
|
blockReplyTimeoutMs?: number;
|
||||||
/** If provided, only load these skills for this session (empty = no skills). */
|
/** If provided, only load these skills for this session (empty = no skills). */
|
||||||
skillFilter?: string[];
|
skillFilter?: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { resolveGatewayLogPaths } from "../daemon/launchd.js";
|
|||||||
import { findLegacyGatewayServices } from "../daemon/legacy.js";
|
import { findLegacyGatewayServices } from "../daemon/legacy.js";
|
||||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
|
import type { ServiceConfigAudit } from "../daemon/service-audit.js";
|
||||||
|
import { auditGatewayServiceConfig } from "../daemon/service-audit.js";
|
||||||
import { callGateway } from "../gateway/call.js";
|
import { callGateway } from "../gateway/call.js";
|
||||||
import { resolveGatewayBindHost } from "../gateway/net.js";
|
import { resolveGatewayBindHost } from "../gateway/net.js";
|
||||||
import {
|
import {
|
||||||
@@ -89,6 +91,7 @@ type DaemonStatus = {
|
|||||||
cachedLabel?: boolean;
|
cachedLabel?: boolean;
|
||||||
missingUnit?: boolean;
|
missingUnit?: boolean;
|
||||||
};
|
};
|
||||||
|
configAudit?: ServiceConfigAudit;
|
||||||
};
|
};
|
||||||
config?: {
|
config?: {
|
||||||
cli: ConfigSummary;
|
cli: ConfigSummary;
|
||||||
@@ -343,6 +346,10 @@ async function gatherDaemonStatus(opts: {
|
|||||||
service.readCommand(process.env).catch(() => null),
|
service.readCommand(process.env).catch(() => null),
|
||||||
service.readRuntime(process.env).catch(() => undefined),
|
service.readRuntime(process.env).catch(() => undefined),
|
||||||
]);
|
]);
|
||||||
|
const configAudit = await auditGatewayServiceConfig({
|
||||||
|
env: process.env,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
|
||||||
const serviceEnv = command?.environment ?? undefined;
|
const serviceEnv = command?.environment ?? undefined;
|
||||||
const mergedDaemonEnv = {
|
const mergedDaemonEnv = {
|
||||||
@@ -484,6 +491,7 @@ async function gatherDaemonStatus(opts: {
|
|||||||
notLoadedText: service.notLoadedText,
|
notLoadedText: service.notLoadedText,
|
||||||
command,
|
command,
|
||||||
runtime,
|
runtime,
|
||||||
|
configAudit,
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
cli: cliConfigSummary,
|
cli: cliConfigSummary,
|
||||||
@@ -538,6 +546,16 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
|
|||||||
if (daemonEnvLines.length > 0) {
|
if (daemonEnvLines.length > 0) {
|
||||||
defaultRuntime.log(`Daemon env: ${daemonEnvLines.join(" ")}`);
|
defaultRuntime.log(`Daemon env: ${daemonEnvLines.join(" ")}`);
|
||||||
}
|
}
|
||||||
|
if (service.configAudit?.issues.length) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
"Service config looks out of date or non-standard.",
|
||||||
|
);
|
||||||
|
for (const issue of service.configAudit.issues) {
|
||||||
|
const detail = issue.detail ? ` (${issue.detail})` : "";
|
||||||
|
defaultRuntime.error(`Service config issue: ${issue.message}${detail}`);
|
||||||
|
}
|
||||||
|
defaultRuntime.error('Recommendation: run "clawdbot doctor".');
|
||||||
|
}
|
||||||
if (status.config) {
|
if (status.config) {
|
||||||
const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
|
const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
|
||||||
defaultRuntime.log(`Config (cli): ${cliCfg}`);
|
defaultRuntime.log(`Config (cli): ${cliCfg}`);
|
||||||
|
|||||||
@@ -37,6 +37,25 @@ type GatewayRpcOpts = {
|
|||||||
expectFinal?: boolean;
|
expectFinal?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GatewayRunOpts = {
|
||||||
|
port?: unknown;
|
||||||
|
bind?: unknown;
|
||||||
|
token?: unknown;
|
||||||
|
auth?: unknown;
|
||||||
|
password?: unknown;
|
||||||
|
tailscale?: unknown;
|
||||||
|
tailscaleResetOnExit?: boolean;
|
||||||
|
allowUnconfigured?: boolean;
|
||||||
|
force?: boolean;
|
||||||
|
verbose?: boolean;
|
||||||
|
wsLog?: unknown;
|
||||||
|
compact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GatewayRunParams = {
|
||||||
|
legacyTokenEnv?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const gatewayLog = createSubsystemLogger("gateway");
|
const gatewayLog = createSubsystemLogger("gateway");
|
||||||
|
|
||||||
type GatewayRunSignalAction = "stop" | "restart";
|
type GatewayRunSignalAction = "stop" | "restart";
|
||||||
@@ -246,10 +265,259 @@ const callGatewayCli = async (
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export function registerGatewayCli(program: Command) {
|
async function runGatewayCommand(
|
||||||
const gateway = program
|
opts: GatewayRunOpts,
|
||||||
.command("gateway")
|
params: GatewayRunParams = {},
|
||||||
.description("Run the WebSocket Gateway")
|
) {
|
||||||
|
if (params.legacyTokenEnv) {
|
||||||
|
const legacyToken = process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||||
|
if (legacyToken && !process.env.CLAWDBOT_GATEWAY_TOKEN) {
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = legacyToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setVerbose(Boolean(opts.verbose));
|
||||||
|
const wsLogRaw = (opts.compact ? "compact" : opts.wsLog) as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
const wsLogStyle: GatewayWsLogStyle =
|
||||||
|
wsLogRaw === "compact" ? "compact" : wsLogRaw === "full" ? "full" : "auto";
|
||||||
|
if (
|
||||||
|
wsLogRaw !== undefined &&
|
||||||
|
wsLogRaw !== "auto" &&
|
||||||
|
wsLogRaw !== "compact" &&
|
||||||
|
wsLogRaw !== "full"
|
||||||
|
) {
|
||||||
|
defaultRuntime.error('Invalid --ws-log (use "auto", "full", "compact")');
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
setGatewayWsLogStyle(wsLogStyle);
|
||||||
|
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const portOverride = parsePort(opts.port);
|
||||||
|
if (opts.port !== undefined && portOverride === null) {
|
||||||
|
defaultRuntime.error("Invalid port");
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
const port = portOverride ?? resolveGatewayPort(cfg);
|
||||||
|
if (!Number.isFinite(port) || port <= 0) {
|
||||||
|
defaultRuntime.error("Invalid port");
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
if (opts.force) {
|
||||||
|
try {
|
||||||
|
const { killed, waitedMs, escalatedToSigkill } =
|
||||||
|
await forceFreePortAndWait(port, {
|
||||||
|
timeoutMs: 2000,
|
||||||
|
intervalMs: 100,
|
||||||
|
sigtermTimeoutMs: 700,
|
||||||
|
});
|
||||||
|
if (killed.length === 0) {
|
||||||
|
gatewayLog.info(`force: no listeners on port ${port}`);
|
||||||
|
} else {
|
||||||
|
for (const proc of killed) {
|
||||||
|
gatewayLog.info(
|
||||||
|
`force: killed pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""} on port ${port}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (escalatedToSigkill) {
|
||||||
|
gatewayLog.info(
|
||||||
|
`force: escalated to SIGKILL while freeing port ${port}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (waitedMs > 0) {
|
||||||
|
gatewayLog.info(
|
||||||
|
`force: waited ${waitedMs}ms for port ${port} to free`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(`Force: ${String(err)}`);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opts.token) {
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = String(opts.token);
|
||||||
|
}
|
||||||
|
const authModeRaw = opts.auth ? String(opts.auth) : undefined;
|
||||||
|
const authMode: GatewayAuthMode | null =
|
||||||
|
authModeRaw === "token" || authModeRaw === "password" ? authModeRaw : null;
|
||||||
|
if (authModeRaw && !authMode) {
|
||||||
|
defaultRuntime.error('Invalid --auth (use "token" or "password")');
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tailscaleRaw = opts.tailscale ? String(opts.tailscale) : undefined;
|
||||||
|
const tailscaleMode =
|
||||||
|
tailscaleRaw === "off" ||
|
||||||
|
tailscaleRaw === "serve" ||
|
||||||
|
tailscaleRaw === "funnel"
|
||||||
|
? tailscaleRaw
|
||||||
|
: null;
|
||||||
|
if (tailscaleRaw && !tailscaleMode) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
'Invalid --tailscale (use "off", "serve", or "funnel")',
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT);
|
||||||
|
const mode = cfg.gateway?.mode;
|
||||||
|
if (!opts.allowUnconfigured && mode !== "local") {
|
||||||
|
if (!configExists) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
"Missing config. Run `clawdbot setup` or set gateway.mode=local (or pass --allow-unconfigured).",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
defaultRuntime.error(
|
||||||
|
`Gateway start blocked: set gateway.mode=local (current: ${mode ?? "unset"}) or pass --allow-unconfigured.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback");
|
||||||
|
const bind =
|
||||||
|
bindRaw === "loopback" ||
|
||||||
|
bindRaw === "tailnet" ||
|
||||||
|
bindRaw === "lan" ||
|
||||||
|
bindRaw === "auto"
|
||||||
|
? bindRaw
|
||||||
|
: null;
|
||||||
|
if (!bind) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
'Invalid --bind (use "loopback", "tailnet", "lan", or "auto")',
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await readConfigFileSnapshot().catch(() => null);
|
||||||
|
const miskeys = extractGatewayMiskeys(snapshot?.parsed);
|
||||||
|
const authConfig = {
|
||||||
|
...cfg.gateway?.auth,
|
||||||
|
...(authMode ? { mode: authMode } : {}),
|
||||||
|
...(opts.password ? { password: String(opts.password) } : {}),
|
||||||
|
...(opts.token ? { token: String(opts.token) } : {}),
|
||||||
|
};
|
||||||
|
const resolvedAuth = resolveGatewayAuth({
|
||||||
|
authConfig,
|
||||||
|
env: process.env,
|
||||||
|
tailscaleMode: tailscaleMode ?? cfg.gateway?.tailscale?.mode ?? "off",
|
||||||
|
});
|
||||||
|
const resolvedAuthMode = resolvedAuth.mode;
|
||||||
|
const tokenValue = resolvedAuth.token;
|
||||||
|
const passwordValue = resolvedAuth.password;
|
||||||
|
const authHints: string[] = [];
|
||||||
|
if (miskeys.hasGatewayToken) {
|
||||||
|
authHints.push(
|
||||||
|
'Found "gateway.token" in config. Use "gateway.auth.token" instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (miskeys.hasRemoteToken) {
|
||||||
|
authHints.push(
|
||||||
|
'"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (resolvedAuthMode === "token" && !tokenValue) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
[
|
||||||
|
"Gateway auth is set to token, but no token is configured.",
|
||||||
|
"Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN), or pass --token.",
|
||||||
|
...authHints,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resolvedAuthMode === "password" && !passwordValue) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
[
|
||||||
|
"Gateway auth is set to password, but no password is configured.",
|
||||||
|
"Set gateway.auth.password (or CLAWDBOT_GATEWAY_PASSWORD), or pass --password.",
|
||||||
|
...authHints,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bind !== "loopback" && resolvedAuthMode === "none") {
|
||||||
|
defaultRuntime.error(
|
||||||
|
[
|
||||||
|
`Refusing to bind gateway to ${bind} without auth.`,
|
||||||
|
"Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) or pass --token.",
|
||||||
|
...authHints,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runGatewayLoop({
|
||||||
|
runtime: defaultRuntime,
|
||||||
|
start: async () =>
|
||||||
|
await startGatewayServer(port, {
|
||||||
|
bind,
|
||||||
|
auth:
|
||||||
|
authMode || opts.password || opts.token || authModeRaw
|
||||||
|
? {
|
||||||
|
mode: authMode ?? undefined,
|
||||||
|
token: opts.token ? String(opts.token) : undefined,
|
||||||
|
password: opts.password ? String(opts.password) : undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
tailscale:
|
||||||
|
tailscaleMode || opts.tailscaleResetOnExit
|
||||||
|
? {
|
||||||
|
mode: tailscaleMode ?? undefined,
|
||||||
|
resetOnExit: Boolean(opts.tailscaleResetOnExit),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof GatewayLockError ||
|
||||||
|
(err &&
|
||||||
|
typeof err === "object" &&
|
||||||
|
(err as { name?: string }).name === "GatewayLockError")
|
||||||
|
) {
|
||||||
|
const errMessage = describeUnknownError(err);
|
||||||
|
defaultRuntime.error(
|
||||||
|
`Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot daemon stop`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const diagnostics = await inspectPortUsage(port);
|
||||||
|
if (diagnostics.status === "busy") {
|
||||||
|
for (const line of formatPortDiagnostics(diagnostics)) {
|
||||||
|
defaultRuntime.error(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore diagnostics failures
|
||||||
|
}
|
||||||
|
await maybeExplainGatewayServiceStop();
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
defaultRuntime.error(`Gateway failed to start: ${String(err)}`);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGatewayRunCommand(
|
||||||
|
cmd: Command,
|
||||||
|
params: GatewayRunParams = {},
|
||||||
|
): Command {
|
||||||
|
return cmd
|
||||||
.option("--port <port>", "Port for the gateway WebSocket")
|
.option("--port <port>", "Port for the gateway WebSocket")
|
||||||
.option(
|
.option(
|
||||||
"--bind <mode>",
|
"--bind <mode>",
|
||||||
@@ -288,252 +556,22 @@ export function registerGatewayCli(program: Command) {
|
|||||||
)
|
)
|
||||||
.option("--compact", 'Alias for "--ws-log compact"', false)
|
.option("--compact", 'Alias for "--ws-log compact"', false)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
await runGatewayCommand(opts, params);
|
||||||
const wsLogRaw = (opts.compact ? "compact" : opts.wsLog) as
|
|
||||||
| string
|
|
||||||
| undefined;
|
|
||||||
const wsLogStyle: GatewayWsLogStyle =
|
|
||||||
wsLogRaw === "compact"
|
|
||||||
? "compact"
|
|
||||||
: wsLogRaw === "full"
|
|
||||||
? "full"
|
|
||||||
: "auto";
|
|
||||||
if (
|
|
||||||
wsLogRaw !== undefined &&
|
|
||||||
wsLogRaw !== "auto" &&
|
|
||||||
wsLogRaw !== "compact" &&
|
|
||||||
wsLogRaw !== "full"
|
|
||||||
) {
|
|
||||||
defaultRuntime.error(
|
|
||||||
'Invalid --ws-log (use "auto", "full", "compact")',
|
|
||||||
);
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
|
||||||
setGatewayWsLogStyle(wsLogStyle);
|
|
||||||
|
|
||||||
const cfg = loadConfig();
|
|
||||||
const portOverride = parsePort(opts.port);
|
|
||||||
if (opts.port !== undefined && portOverride === null) {
|
|
||||||
defaultRuntime.error("Invalid port");
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
|
||||||
const port = portOverride ?? resolveGatewayPort(cfg);
|
|
||||||
if (!Number.isFinite(port) || port <= 0) {
|
|
||||||
defaultRuntime.error("Invalid port");
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
|
||||||
if (opts.force) {
|
|
||||||
try {
|
|
||||||
const { killed, waitedMs, escalatedToSigkill } =
|
|
||||||
await forceFreePortAndWait(port, {
|
|
||||||
timeoutMs: 2000,
|
|
||||||
intervalMs: 100,
|
|
||||||
sigtermTimeoutMs: 700,
|
|
||||||
});
|
|
||||||
if (killed.length === 0) {
|
|
||||||
gatewayLog.info(`force: no listeners on port ${port}`);
|
|
||||||
} else {
|
|
||||||
for (const proc of killed) {
|
|
||||||
gatewayLog.info(
|
|
||||||
`force: killed pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""} on port ${port}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (escalatedToSigkill) {
|
|
||||||
gatewayLog.info(
|
|
||||||
`force: escalated to SIGKILL while freeing port ${port}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (waitedMs > 0) {
|
|
||||||
gatewayLog.info(
|
|
||||||
`force: waited ${waitedMs}ms for port ${port} to free`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
defaultRuntime.error(`Force: ${String(err)}`);
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (opts.token) {
|
|
||||||
process.env.CLAWDBOT_GATEWAY_TOKEN = String(opts.token);
|
|
||||||
}
|
|
||||||
const authModeRaw = opts.auth ? String(opts.auth) : undefined;
|
|
||||||
const authMode: GatewayAuthMode | null =
|
|
||||||
authModeRaw === "token" || authModeRaw === "password"
|
|
||||||
? authModeRaw
|
|
||||||
: null;
|
|
||||||
if (authModeRaw && !authMode) {
|
|
||||||
defaultRuntime.error('Invalid --auth (use "token" or "password")');
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tailscaleRaw = opts.tailscale ? String(opts.tailscale) : undefined;
|
|
||||||
const tailscaleMode =
|
|
||||||
tailscaleRaw === "off" ||
|
|
||||||
tailscaleRaw === "serve" ||
|
|
||||||
tailscaleRaw === "funnel"
|
|
||||||
? tailscaleRaw
|
|
||||||
: null;
|
|
||||||
if (tailscaleRaw && !tailscaleMode) {
|
|
||||||
defaultRuntime.error(
|
|
||||||
'Invalid --tailscale (use "off", "serve", or "funnel")',
|
|
||||||
);
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT);
|
|
||||||
const mode = cfg.gateway?.mode;
|
|
||||||
if (!opts.allowUnconfigured && mode !== "local") {
|
|
||||||
if (!configExists) {
|
|
||||||
defaultRuntime.error(
|
|
||||||
"Missing config. Run `clawdbot setup` or set gateway.mode=local (or pass --allow-unconfigured).",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
defaultRuntime.error(
|
|
||||||
`Gateway start blocked: set gateway.mode=local (current: ${mode ?? "unset"}) or pass --allow-unconfigured.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback");
|
|
||||||
const bind =
|
|
||||||
bindRaw === "loopback" ||
|
|
||||||
bindRaw === "tailnet" ||
|
|
||||||
bindRaw === "lan" ||
|
|
||||||
bindRaw === "auto"
|
|
||||||
? bindRaw
|
|
||||||
: null;
|
|
||||||
if (!bind) {
|
|
||||||
defaultRuntime.error(
|
|
||||||
'Invalid --bind (use "loopback", "tailnet", "lan", or "auto")',
|
|
||||||
);
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = await readConfigFileSnapshot().catch(() => null);
|
|
||||||
const miskeys = extractGatewayMiskeys(snapshot?.parsed);
|
|
||||||
const authConfig = {
|
|
||||||
...cfg.gateway?.auth,
|
|
||||||
...(authMode ? { mode: authMode } : {}),
|
|
||||||
...(opts.password ? { password: String(opts.password) } : {}),
|
|
||||||
...(opts.token ? { token: String(opts.token) } : {}),
|
|
||||||
};
|
|
||||||
const resolvedAuth = resolveGatewayAuth({
|
|
||||||
authConfig,
|
|
||||||
env: process.env,
|
|
||||||
tailscaleMode: tailscaleMode ?? cfg.gateway?.tailscale?.mode ?? "off",
|
|
||||||
});
|
|
||||||
const resolvedAuthMode = resolvedAuth.mode;
|
|
||||||
const tokenValue = resolvedAuth.token;
|
|
||||||
const passwordValue = resolvedAuth.password;
|
|
||||||
const authHints: string[] = [];
|
|
||||||
if (miskeys.hasGatewayToken) {
|
|
||||||
authHints.push(
|
|
||||||
'Found "gateway.token" in config. Use "gateway.auth.token" instead.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (miskeys.hasRemoteToken) {
|
|
||||||
authHints.push(
|
|
||||||
'"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (resolvedAuthMode === "token" && !tokenValue) {
|
|
||||||
defaultRuntime.error(
|
|
||||||
[
|
|
||||||
"Gateway auth is set to token, but no token is configured.",
|
|
||||||
"Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN), or pass --token.",
|
|
||||||
...authHints,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n"),
|
|
||||||
);
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (resolvedAuthMode === "password" && !passwordValue) {
|
|
||||||
defaultRuntime.error(
|
|
||||||
[
|
|
||||||
"Gateway auth is set to password, but no password is configured.",
|
|
||||||
"Set gateway.auth.password (or CLAWDBOT_GATEWAY_PASSWORD), or pass --password.",
|
|
||||||
...authHints,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n"),
|
|
||||||
);
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (bind !== "loopback" && resolvedAuthMode === "none") {
|
|
||||||
defaultRuntime.error(
|
|
||||||
[
|
|
||||||
`Refusing to bind gateway to ${bind} without auth.`,
|
|
||||||
"Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) or pass --token.",
|
|
||||||
...authHints,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n"),
|
|
||||||
);
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await runGatewayLoop({
|
|
||||||
runtime: defaultRuntime,
|
|
||||||
start: async () =>
|
|
||||||
await startGatewayServer(port, {
|
|
||||||
bind,
|
|
||||||
auth:
|
|
||||||
authMode || opts.password || opts.token || authModeRaw
|
|
||||||
? {
|
|
||||||
mode: authMode ?? undefined,
|
|
||||||
token: opts.token ? String(opts.token) : undefined,
|
|
||||||
password: opts.password
|
|
||||||
? String(opts.password)
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
tailscale:
|
|
||||||
tailscaleMode || opts.tailscaleResetOnExit
|
|
||||||
? {
|
|
||||||
mode: tailscaleMode ?? undefined,
|
|
||||||
resetOnExit: Boolean(opts.tailscaleResetOnExit),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (
|
|
||||||
err instanceof GatewayLockError ||
|
|
||||||
(err &&
|
|
||||||
typeof err === "object" &&
|
|
||||||
(err as { name?: string }).name === "GatewayLockError")
|
|
||||||
) {
|
|
||||||
const errMessage = describeUnknownError(err);
|
|
||||||
defaultRuntime.error(
|
|
||||||
`Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot daemon stop`,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
const diagnostics = await inspectPortUsage(port);
|
|
||||||
if (diagnostics.status === "busy") {
|
|
||||||
for (const line of formatPortDiagnostics(diagnostics)) {
|
|
||||||
defaultRuntime.error(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore diagnostics failures
|
|
||||||
}
|
|
||||||
await maybeExplainGatewayServiceStop();
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
defaultRuntime.error(`Gateway failed to start: ${String(err)}`);
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerGatewayCli(program: Command) {
|
||||||
|
const gateway = addGatewayRunCommand(
|
||||||
|
program.command("gateway").description("Run the WebSocket Gateway"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Back-compat: legacy launchd plists used gateway-daemon; keep hidden alias.
|
||||||
|
addGatewayRunCommand(
|
||||||
|
program
|
||||||
|
.command("gateway-daemon", { hidden: true })
|
||||||
|
.description("Run the WebSocket Gateway as a long-lived daemon"),
|
||||||
|
{ legacyTokenEnv: true },
|
||||||
|
);
|
||||||
|
|
||||||
gatewayCallOpts(
|
gatewayCallOpts(
|
||||||
gateway
|
gateway
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ describe("agentCommand", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes telegram account id when delivering", async () => {
|
it("passes through telegram accountId when delivering", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const store = path.join(home, "sessions.json");
|
const store = path.join(home, "sessions.json");
|
||||||
mockConfig(home, store, undefined, undefined, { botToken: "t-1" });
|
mockConfig(home, store, undefined, undefined, { botToken: "t-1" });
|
||||||
@@ -297,7 +297,7 @@ describe("agentCommand", () => {
|
|||||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||||
"123",
|
"123",
|
||||||
"ok",
|
"ok",
|
||||||
expect.objectContaining({ accountId: "default", verbose: false }),
|
expect.objectContaining({ accountId: undefined, verbose: false }),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (prevTelegramToken === undefined) {
|
if (prevTelegramToken === undefined) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "../daemon/legacy.js";
|
} from "../daemon/legacy.js";
|
||||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
|
import { auditGatewayServiceConfig } from "../daemon/service-audit.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||||
@@ -23,6 +24,18 @@ import {
|
|||||||
} from "./daemon-runtime.js";
|
} from "./daemon-runtime.js";
|
||||||
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
|
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
|
||||||
|
|
||||||
|
function detectGatewayRuntime(
|
||||||
|
programArguments: string[] | undefined,
|
||||||
|
): GatewayDaemonRuntime {
|
||||||
|
const first = programArguments?.[0];
|
||||||
|
if (first) {
|
||||||
|
const base = path.basename(first).toLowerCase();
|
||||||
|
if (base === "bun" || base === "bun.exe") return "bun";
|
||||||
|
if (base === "node" || base === "node.exe") return "node";
|
||||||
|
}
|
||||||
|
return DEFAULT_GATEWAY_DAEMON_RUNTIME;
|
||||||
|
}
|
||||||
|
|
||||||
export async function maybeMigrateLegacyGatewayService(
|
export async function maybeMigrateLegacyGatewayService(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
mode: "local" | "remote",
|
mode: "local" | "remote",
|
||||||
@@ -112,6 +125,83 @@ export async function maybeMigrateLegacyGatewayService(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function maybeRepairGatewayServiceConfig(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
mode: "local" | "remote",
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
prompter: DoctorPrompter,
|
||||||
|
) {
|
||||||
|
if (resolveIsNixMode(process.env)) {
|
||||||
|
note("Nix mode detected; skip service updates.", "Gateway");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "remote") {
|
||||||
|
note("Gateway mode is remote; skipped local service audit.", "Gateway");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = resolveGatewayService();
|
||||||
|
const command = await service.readCommand(process.env).catch(() => null);
|
||||||
|
if (!command) return;
|
||||||
|
|
||||||
|
const audit = await auditGatewayServiceConfig({
|
||||||
|
env: process.env,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
if (audit.issues.length === 0) return;
|
||||||
|
|
||||||
|
note(
|
||||||
|
audit.issues
|
||||||
|
.map((issue) =>
|
||||||
|
issue.detail ? `- ${issue.message} (${issue.detail})` : `- ${issue.message}`,
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
"Gateway service config",
|
||||||
|
);
|
||||||
|
|
||||||
|
const repair = await prompter.confirmSkipInNonInteractive({
|
||||||
|
message: "Update gateway service config to the recommended defaults now?",
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (!repair) return;
|
||||||
|
|
||||||
|
const devMode =
|
||||||
|
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||||
|
process.argv[1]?.endsWith(".ts");
|
||||||
|
const port = resolveGatewayPort(cfg, process.env);
|
||||||
|
const runtimeChoice = detectGatewayRuntime(command.programArguments);
|
||||||
|
const { programArguments, workingDirectory } =
|
||||||
|
await resolveGatewayProgramArguments({
|
||||||
|
port,
|
||||||
|
dev: devMode,
|
||||||
|
runtime: runtimeChoice,
|
||||||
|
});
|
||||||
|
const environment: Record<string, string | undefined> = {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
|
||||||
|
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
|
||||||
|
CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
|
||||||
|
CLAWDBOT_GATEWAY_PORT: String(port),
|
||||||
|
CLAWDBOT_GATEWAY_TOKEN:
|
||||||
|
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||||
|
CLAWDBOT_LAUNCHD_LABEL:
|
||||||
|
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.install({
|
||||||
|
env: process.env,
|
||||||
|
stdout: process.stdout,
|
||||||
|
programArguments,
|
||||||
|
workingDirectory,
|
||||||
|
environment,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error(`Gateway service update failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function maybeScanExtraGatewayServices(options: DoctorOptions) {
|
export async function maybeScanExtraGatewayServices(options: DoctorOptions) {
|
||||||
const extraServices = await findExtraGatewayServices(process.env, {
|
const extraServices = await findExtraGatewayServices(process.env, {
|
||||||
deep: options.deep,
|
deep: options.deep,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from "./doctor-format.js";
|
} from "./doctor-format.js";
|
||||||
import {
|
import {
|
||||||
maybeMigrateLegacyGatewayService,
|
maybeMigrateLegacyGatewayService,
|
||||||
|
maybeRepairGatewayServiceConfig,
|
||||||
maybeScanExtraGatewayServices,
|
maybeScanExtraGatewayServices,
|
||||||
} from "./doctor-gateway-services.js";
|
} from "./doctor-gateway-services.js";
|
||||||
import {
|
import {
|
||||||
@@ -157,6 +158,12 @@ export async function doctorCommand(
|
|||||||
prompter,
|
prompter,
|
||||||
);
|
);
|
||||||
await maybeScanExtraGatewayServices(options);
|
await maybeScanExtraGatewayServices(options);
|
||||||
|
await maybeRepairGatewayServiceConfig(
|
||||||
|
cfg,
|
||||||
|
resolveMode(cfg),
|
||||||
|
runtime,
|
||||||
|
prompter,
|
||||||
|
);
|
||||||
|
|
||||||
await noteSecurityWarnings(cfg);
|
await noteSecurityWarnings(cfg);
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ describe("sendCommand", () => {
|
|||||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||||
"123",
|
"123",
|
||||||
"hi",
|
"hi",
|
||||||
expect.objectContaining({ accountId: "default", verbose: false }),
|
expect.objectContaining({ accountId: undefined, verbose: false }),
|
||||||
);
|
);
|
||||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -158,7 +158,7 @@ describe("sendCommand", () => {
|
|||||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||||
"123",
|
"123",
|
||||||
"hi",
|
"hi",
|
||||||
expect.objectContaining({ accountId: "default", verbose: false }),
|
expect.objectContaining({ accountId: undefined, verbose: false }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ describe("sendCommand", () => {
|
|||||||
expect(deps.sendMessageSlack).toHaveBeenCalledWith(
|
expect(deps.sendMessageSlack).toHaveBeenCalledWith(
|
||||||
"channel:C123",
|
"channel:C123",
|
||||||
"hi",
|
"hi",
|
||||||
expect.objectContaining({ accountId: "default" }),
|
expect.objectContaining({ accountId: undefined }),
|
||||||
);
|
);
|
||||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -716,4 +716,20 @@ describe("parseTelegramTarget", () => {
|
|||||||
topicId: undefined,
|
topicId: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips internal telegram prefix", () => {
|
||||||
|
expect(parseTelegramTarget("telegram:123")).toEqual({
|
||||||
|
chatId: "123",
|
||||||
|
topicId: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips internal telegram + group prefixes before parsing topic", () => {
|
||||||
|
expect(
|
||||||
|
parseTelegramTarget("telegram:group:-1001234567890:topic:456"),
|
||||||
|
).toEqual({
|
||||||
|
chatId: "-1001234567890",
|
||||||
|
topicId: 456,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,7 +61,16 @@ export function parseTelegramTarget(to: string): {
|
|||||||
chatId: string;
|
chatId: string;
|
||||||
topicId: number | undefined;
|
topicId: number | undefined;
|
||||||
} {
|
} {
|
||||||
const trimmed = to.trim();
|
let trimmed = to.trim();
|
||||||
|
|
||||||
|
// Cron "lastTo" values can include internal prefixes like `telegram:...` or
|
||||||
|
// `telegram:group:...` (see normalizeChatId in telegram/send.ts).
|
||||||
|
// Strip these before parsing `:topic:` / `:<topicId>` suffixes.
|
||||||
|
while (true) {
|
||||||
|
const next = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
|
||||||
|
if (next === trimmed) break;
|
||||||
|
trimmed = next;
|
||||||
|
}
|
||||||
|
|
||||||
// Try format: chatId:topic:topicId
|
// Try format: chatId:topic:topicId
|
||||||
const topicMatch = /^(.+?):topic:(\d+)$/.exec(trimmed);
|
const topicMatch = /^(.+?):topic:(\d+)$/.exec(trimmed);
|
||||||
|
|||||||
165
src/daemon/service-audit.ts
Normal file
165
src/daemon/service-audit.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { resolveLaunchAgentPlistPath } from "./launchd.js";
|
||||||
|
import { resolveSystemdUserUnitPath } from "./systemd.js";
|
||||||
|
|
||||||
|
export type GatewayServiceCommand = {
|
||||||
|
programArguments: string[];
|
||||||
|
workingDirectory?: string;
|
||||||
|
environment?: Record<string, string>;
|
||||||
|
sourcePath?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export type ServiceConfigIssue = {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceConfigAudit = {
|
||||||
|
ok: boolean;
|
||||||
|
issues: ServiceConfigIssue[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasGatewaySubcommand(programArguments?: string[]): boolean {
|
||||||
|
return Boolean(programArguments?.some((arg) => arg === "gateway"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSystemdUnit(content: string): {
|
||||||
|
after: Set<string>;
|
||||||
|
wants: Set<string>;
|
||||||
|
restartSec?: string;
|
||||||
|
} {
|
||||||
|
const after = new Set<string>();
|
||||||
|
const wants = new Set<string>();
|
||||||
|
let restartSec: string | undefined;
|
||||||
|
|
||||||
|
for (const rawLine of content.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
if (line.startsWith("#") || line.startsWith(";")) continue;
|
||||||
|
if (line.startsWith("[")) continue;
|
||||||
|
const idx = line.indexOf("=");
|
||||||
|
if (idx <= 0) continue;
|
||||||
|
const key = line.slice(0, idx).trim();
|
||||||
|
const value = line.slice(idx + 1).trim();
|
||||||
|
if (!value) continue;
|
||||||
|
if (key === "After") {
|
||||||
|
for (const entry of value.split(/\s+/)) {
|
||||||
|
if (entry) after.add(entry);
|
||||||
|
}
|
||||||
|
} else if (key === "Wants") {
|
||||||
|
for (const entry of value.split(/\s+/)) {
|
||||||
|
if (entry) wants.add(entry);
|
||||||
|
}
|
||||||
|
} else if (key === "RestartSec") {
|
||||||
|
restartSec = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { after, wants, restartSec };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRestartSecPreferred(value: string | undefined): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
if (!Number.isFinite(parsed)) return false;
|
||||||
|
return Math.abs(parsed - 5) < 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auditSystemdUnit(
|
||||||
|
env: Record<string, string | undefined>,
|
||||||
|
issues: ServiceConfigIssue[],
|
||||||
|
) {
|
||||||
|
const unitPath = resolveSystemdUserUnitPath(env);
|
||||||
|
let content = "";
|
||||||
|
try {
|
||||||
|
content = await fs.readFile(unitPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseSystemdUnit(content);
|
||||||
|
if (!parsed.after.has("network-online.target")) {
|
||||||
|
issues.push({
|
||||||
|
code: "systemd-after-network-online",
|
||||||
|
message: "Missing systemd After=network-online.target",
|
||||||
|
detail: unitPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!parsed.wants.has("network-online.target")) {
|
||||||
|
issues.push({
|
||||||
|
code: "systemd-wants-network-online",
|
||||||
|
message: "Missing systemd Wants=network-online.target",
|
||||||
|
detail: unitPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!isRestartSecPreferred(parsed.restartSec)) {
|
||||||
|
issues.push({
|
||||||
|
code: "systemd-restart-sec",
|
||||||
|
message: "RestartSec does not match the recommended 5s",
|
||||||
|
detail: unitPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auditLaunchdPlist(
|
||||||
|
env: Record<string, string | undefined>,
|
||||||
|
issues: ServiceConfigIssue[],
|
||||||
|
) {
|
||||||
|
const plistPath = resolveLaunchAgentPlistPath(env);
|
||||||
|
let content = "";
|
||||||
|
try {
|
||||||
|
content = await fs.readFile(plistPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRunAtLoad = /<key>RunAtLoad<\/key>\s*<true\s*\/>/i.test(content);
|
||||||
|
const hasKeepAlive = /<key>KeepAlive<\/key>\s*<true\s*\/>/i.test(content);
|
||||||
|
if (!hasRunAtLoad) {
|
||||||
|
issues.push({
|
||||||
|
code: "launchd-run-at-load",
|
||||||
|
message: "LaunchAgent is missing RunAtLoad=true",
|
||||||
|
detail: plistPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!hasKeepAlive) {
|
||||||
|
issues.push({
|
||||||
|
code: "launchd-keep-alive",
|
||||||
|
message: "LaunchAgent is missing KeepAlive=true",
|
||||||
|
detail: plistPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditGatewayCommand(
|
||||||
|
programArguments: string[] | undefined,
|
||||||
|
issues: ServiceConfigIssue[],
|
||||||
|
) {
|
||||||
|
if (!programArguments || programArguments.length === 0) return;
|
||||||
|
if (!hasGatewaySubcommand(programArguments)) {
|
||||||
|
issues.push({
|
||||||
|
code: "gateway-command-missing",
|
||||||
|
message: "Service command does not include the gateway subcommand",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function auditGatewayServiceConfig(params: {
|
||||||
|
env: Record<string, string | undefined>;
|
||||||
|
command: GatewayServiceCommand;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
}): Promise<ServiceConfigAudit> {
|
||||||
|
const issues: ServiceConfigIssue[] = [];
|
||||||
|
const platform = params.platform ?? process.platform;
|
||||||
|
|
||||||
|
auditGatewayCommand(params.command?.programArguments, issues);
|
||||||
|
|
||||||
|
if (platform === "linux") {
|
||||||
|
await auditSystemdUnit(params.env, issues);
|
||||||
|
} else if (platform === "darwin") {
|
||||||
|
await auditLaunchdPlist(params.env, issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: issues.length === 0, issues };
|
||||||
|
}
|
||||||
@@ -33,6 +33,12 @@ function resolveSystemdUnitPath(
|
|||||||
return resolveSystemdUnitPathForName(env, GATEWAY_SYSTEMD_SERVICE_NAME);
|
return resolveSystemdUnitPathForName(env, GATEWAY_SYSTEMD_SERVICE_NAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveSystemdUserUnitPath(
|
||||||
|
env: Record<string, string | undefined>,
|
||||||
|
): string {
|
||||||
|
return resolveSystemdUnitPath(env);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveLoginctlUser(
|
function resolveLoginctlUser(
|
||||||
env: Record<string, string | undefined>,
|
env: Record<string, string | undefined>,
|
||||||
): string | null {
|
): string | null {
|
||||||
@@ -141,10 +147,13 @@ function buildSystemdUnit({
|
|||||||
return [
|
return [
|
||||||
"[Unit]",
|
"[Unit]",
|
||||||
"Description=Clawdbot Gateway",
|
"Description=Clawdbot Gateway",
|
||||||
|
"After=network-online.target",
|
||||||
|
"Wants=network-online.target",
|
||||||
"",
|
"",
|
||||||
"[Service]",
|
"[Service]",
|
||||||
`ExecStart=${execStart}`,
|
`ExecStart=${execStart}`,
|
||||||
"Restart=always",
|
"Restart=always",
|
||||||
|
"RestartSec=5",
|
||||||
workingDirLine,
|
workingDirLine,
|
||||||
...envLines,
|
...envLines,
|
||||||
"",
|
"",
|
||||||
|
|||||||
61
src/discord/monitor.gateway.test.ts
Normal file
61
src/discord/monitor.gateway.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { waitForDiscordGatewayStop } from "./monitor.gateway.js";
|
||||||
|
|
||||||
|
describe("waitForDiscordGatewayStop", () => {
|
||||||
|
it("resolves on abort and disconnects gateway", async () => {
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
const disconnect = vi.fn();
|
||||||
|
const abort = new AbortController();
|
||||||
|
|
||||||
|
const promise = waitForDiscordGatewayStop({
|
||||||
|
gateway: { emitter, disconnect },
|
||||||
|
abortSignal: abort.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(emitter.listenerCount("error")).toBe(1);
|
||||||
|
abort.abort();
|
||||||
|
|
||||||
|
await expect(promise).resolves.toBeUndefined();
|
||||||
|
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(emitter.listenerCount("error")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects on gateway error and disconnects", async () => {
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
const disconnect = vi.fn();
|
||||||
|
const onGatewayError = vi.fn();
|
||||||
|
const abort = new AbortController();
|
||||||
|
const err = new Error("boom");
|
||||||
|
|
||||||
|
const promise = waitForDiscordGatewayStop({
|
||||||
|
gateway: { emitter, disconnect },
|
||||||
|
abortSignal: abort.signal,
|
||||||
|
onGatewayError,
|
||||||
|
});
|
||||||
|
|
||||||
|
emitter.emit("error", err);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow("boom");
|
||||||
|
expect(onGatewayError).toHaveBeenCalledWith(err);
|
||||||
|
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(emitter.listenerCount("error")).toBe(0);
|
||||||
|
|
||||||
|
abort.abort();
|
||||||
|
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves on abort without a gateway", async () => {
|
||||||
|
const abort = new AbortController();
|
||||||
|
|
||||||
|
const promise = waitForDiscordGatewayStop({
|
||||||
|
abortSignal: abort.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
abort.abort();
|
||||||
|
|
||||||
|
await expect(promise).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
63
src/discord/monitor.gateway.ts
Normal file
63
src/discord/monitor.gateway.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
export type DiscordGatewayHandle = {
|
||||||
|
emitter?: Pick<EventEmitter, "on" | "removeListener">;
|
||||||
|
disconnect?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDiscordGatewayEmitter(
|
||||||
|
gateway?: unknown,
|
||||||
|
): EventEmitter | undefined {
|
||||||
|
return (gateway as { emitter?: EventEmitter } | undefined)?.emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForDiscordGatewayStop(params: {
|
||||||
|
gateway?: DiscordGatewayHandle;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
onGatewayError?: (err: unknown) => void;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { gateway, abortSignal, onGatewayError } = params;
|
||||||
|
const emitter = gateway?.emitter;
|
||||||
|
return await new Promise<void>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const cleanup = () => {
|
||||||
|
abortSignal?.removeEventListener("abort", onAbort);
|
||||||
|
emitter?.removeListener("error", onGatewayErrorEvent);
|
||||||
|
};
|
||||||
|
const finishResolve = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
try {
|
||||||
|
gateway?.disconnect?.();
|
||||||
|
} finally {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const finishReject = (err: unknown) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
try {
|
||||||
|
gateway?.disconnect?.();
|
||||||
|
} finally {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onAbort = () => {
|
||||||
|
finishResolve();
|
||||||
|
};
|
||||||
|
const onGatewayErrorEvent = (err: unknown) => {
|
||||||
|
onGatewayError?.(err);
|
||||||
|
finishReject(err);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (abortSignal?.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
emitter?.on("error", onGatewayErrorEvent);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -61,6 +61,10 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
import { resolveDiscordAccount } from "./accounts.js";
|
import { resolveDiscordAccount } from "./accounts.js";
|
||||||
import { chunkDiscordText } from "./chunk.js";
|
import { chunkDiscordText } from "./chunk.js";
|
||||||
|
import {
|
||||||
|
getDiscordGatewayEmitter,
|
||||||
|
waitForDiscordGatewayStop,
|
||||||
|
} from "./monitor.gateway.js";
|
||||||
import { fetchDiscordApplicationId } from "./probe.js";
|
import { fetchDiscordApplicationId } from "./probe.js";
|
||||||
import { reactMessageDiscord, sendMessageDiscord } from "./send.js";
|
import { reactMessageDiscord, sendMessageDiscord } from "./send.js";
|
||||||
import { normalizeDiscordToken } from "./token.js";
|
import { normalizeDiscordToken } from "./token.js";
|
||||||
@@ -402,18 +406,19 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
|
|
||||||
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
|
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||||
const onAbort = async () => {
|
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
|
||||||
try {
|
await waitForDiscordGatewayStop({
|
||||||
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
gateway: gateway
|
||||||
gateway?.disconnect();
|
? {
|
||||||
} finally {
|
emitter: gatewayEmitter,
|
||||||
resolve();
|
disconnect: () => gateway.disconnect(),
|
||||||
}
|
}
|
||||||
};
|
: undefined,
|
||||||
opts.abortSignal?.addEventListener("abort", () => {
|
abortSignal: opts.abortSignal,
|
||||||
void onAbort();
|
onGatewayError: (err) => {
|
||||||
});
|
runtime.error?.(danger(`discord gateway error: ${String(err)}`));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes telegram token from config to sendTelegram", async () => {
|
it("passes through accountId for telegram heartbeats", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||||
const storePath = path.join(tmpDir, "sessions.json");
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
@@ -418,7 +418,74 @@ describe("runHeartbeatOnce", () => {
|
|||||||
expect(sendTelegram).toHaveBeenCalledWith(
|
expect(sendTelegram).toHaveBeenCalledWith(
|
||||||
"123456",
|
"123456",
|
||||||
"Hello from heartbeat",
|
"Hello from heartbeat",
|
||||||
expect.objectContaining({ accountId: "default", verbose: false }),
|
expect.objectContaining({ accountId: undefined, verbose: false }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
replySpy.mockRestore();
|
||||||
|
if (prevTelegramToken === undefined) {
|
||||||
|
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
|
||||||
|
}
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pre-resolve telegram accountId (allows config-only account tokens)", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
|
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||||
|
try {
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
main: {
|
||||||
|
sessionId: "sid",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastProvider: "telegram",
|
||||||
|
lastTo: "123456",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agent: {
|
||||||
|
heartbeat: { every: "5m", target: "telegram", to: "123456" },
|
||||||
|
},
|
||||||
|
telegram: {
|
||||||
|
accounts: {
|
||||||
|
work: { botToken: "test-bot-token-123" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: { store: storePath },
|
||||||
|
};
|
||||||
|
|
||||||
|
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
|
||||||
|
const sendTelegram = vi.fn().mockResolvedValue({
|
||||||
|
messageId: "m1",
|
||||||
|
chatId: "123456",
|
||||||
|
});
|
||||||
|
|
||||||
|
await runHeartbeatOnce({
|
||||||
|
cfg,
|
||||||
|
deps: {
|
||||||
|
sendTelegram,
|
||||||
|
getQueueSize: () => 0,
|
||||||
|
nowMs: () => 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendTelegram).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendTelegram).toHaveBeenCalledWith(
|
||||||
|
"123456",
|
||||||
|
"Hello from heartbeat",
|
||||||
|
expect.objectContaining({ accountId: undefined, verbose: false }),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
replySpy.mockRestore();
|
replySpy.mockRestore();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "./deliver.js";
|
} from "./deliver.js";
|
||||||
|
|
||||||
describe("deliverOutboundPayloads", () => {
|
describe("deliverOutboundPayloads", () => {
|
||||||
it("chunks telegram markdown and passes account id", async () => {
|
it("chunks telegram markdown and passes through accountId", async () => {
|
||||||
const sendTelegram = vi
|
const sendTelegram = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
.mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||||
@@ -28,7 +28,7 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
expect(sendTelegram).toHaveBeenCalledTimes(2);
|
expect(sendTelegram).toHaveBeenCalledTimes(2);
|
||||||
for (const call of sendTelegram.mock.calls) {
|
for (const call of sendTelegram.mock.calls) {
|
||||||
expect(call[2]).toEqual(
|
expect(call[2]).toEqual(
|
||||||
expect.objectContaining({ accountId: "default", verbose: false }),
|
expect.objectContaining({ accountId: undefined, verbose: false }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
expect(results).toHaveLength(2);
|
expect(results).toHaveLength(2);
|
||||||
@@ -42,6 +42,30 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes explicit accountId to sendTelegram", async () => {
|
||||||
|
const sendTelegram = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
telegram: { botToken: "tok-1", textChunkLimit: 2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
await deliverOutboundPayloads({
|
||||||
|
cfg,
|
||||||
|
provider: "telegram",
|
||||||
|
to: "123",
|
||||||
|
accountId: "default",
|
||||||
|
payloads: [{ text: "hi" }],
|
||||||
|
deps: { sendTelegram },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendTelegram).toHaveBeenCalledWith(
|
||||||
|
"123",
|
||||||
|
"hi",
|
||||||
|
expect.objectContaining({ accountId: "default", verbose: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses signal media maxBytes from config", async () => {
|
it("uses signal media maxBytes from config", async () => {
|
||||||
const sendSignal = vi
|
const sendSignal = vi
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ function createProviderHandler(params: {
|
|||||||
deps: Required<OutboundSendDeps>;
|
deps: Required<OutboundSendDeps>;
|
||||||
}): ProviderHandler {
|
}): ProviderHandler {
|
||||||
const { cfg, to, deps } = params;
|
const { cfg, to, deps } = params;
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const rawAccountId = params.accountId;
|
||||||
|
const accountId = normalizeAccountId(rawAccountId);
|
||||||
const signalMaxBytes =
|
const signalMaxBytes =
|
||||||
params.provider === "signal"
|
params.provider === "signal"
|
||||||
? resolveMediaMaxBytes(cfg, "signal", accountId)
|
? resolveMediaMaxBytes(cfg, "signal", accountId)
|
||||||
@@ -103,7 +104,7 @@ function createProviderHandler(params: {
|
|||||||
provider: "whatsapp",
|
provider: "whatsapp",
|
||||||
...(await deps.sendWhatsApp(to, text, {
|
...(await deps.sendWhatsApp(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
accountId,
|
accountId: rawAccountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
sendMedia: async (caption, mediaUrl) => ({
|
sendMedia: async (caption, mediaUrl) => ({
|
||||||
@@ -111,7 +112,7 @@ function createProviderHandler(params: {
|
|||||||
...(await deps.sendWhatsApp(to, caption, {
|
...(await deps.sendWhatsApp(to, caption, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
accountId,
|
accountId: rawAccountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -121,7 +122,7 @@ function createProviderHandler(params: {
|
|||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
...(await deps.sendTelegram(to, text, {
|
...(await deps.sendTelegram(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
accountId,
|
accountId: rawAccountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
sendMedia: async (caption, mediaUrl) => ({
|
sendMedia: async (caption, mediaUrl) => ({
|
||||||
@@ -129,7 +130,7 @@ function createProviderHandler(params: {
|
|||||||
...(await deps.sendTelegram(to, caption, {
|
...(await deps.sendTelegram(to, caption, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
accountId,
|
accountId: rawAccountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -139,7 +140,7 @@ function createProviderHandler(params: {
|
|||||||
provider: "discord",
|
provider: "discord",
|
||||||
...(await deps.sendDiscord(to, text, {
|
...(await deps.sendDiscord(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
accountId,
|
accountId: rawAccountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
sendMedia: async (caption, mediaUrl) => ({
|
sendMedia: async (caption, mediaUrl) => ({
|
||||||
@@ -147,7 +148,7 @@ function createProviderHandler(params: {
|
|||||||
...(await deps.sendDiscord(to, caption, {
|
...(await deps.sendDiscord(to, caption, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
accountId,
|
accountId: rawAccountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -156,14 +157,14 @@ function createProviderHandler(params: {
|
|||||||
sendText: async (text) => ({
|
sendText: async (text) => ({
|
||||||
provider: "slack",
|
provider: "slack",
|
||||||
...(await deps.sendSlack(to, text, {
|
...(await deps.sendSlack(to, text, {
|
||||||
accountId,
|
accountId: rawAccountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
sendMedia: async (caption, mediaUrl) => ({
|
sendMedia: async (caption, mediaUrl) => ({
|
||||||
provider: "slack",
|
provider: "slack",
|
||||||
...(await deps.sendSlack(to, caption, {
|
...(await deps.sendSlack(to, caption, {
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
accountId,
|
accountId: rawAccountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -173,7 +174,7 @@ function createProviderHandler(params: {
|
|||||||
provider: "signal",
|
provider: "signal",
|
||||||
...(await deps.sendSignal(to, text, {
|
...(await deps.sendSignal(to, text, {
|
||||||
maxBytes: signalMaxBytes,
|
maxBytes: signalMaxBytes,
|
||||||
accountId,
|
accountId: rawAccountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
sendMedia: async (caption, mediaUrl) => ({
|
sendMedia: async (caption, mediaUrl) => ({
|
||||||
@@ -181,7 +182,7 @@ function createProviderHandler(params: {
|
|||||||
...(await deps.sendSignal(to, caption, {
|
...(await deps.sendSignal(to, caption, {
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
maxBytes: signalMaxBytes,
|
maxBytes: signalMaxBytes,
|
||||||
accountId,
|
accountId: rawAccountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -191,7 +192,7 @@ function createProviderHandler(params: {
|
|||||||
provider: "imessage",
|
provider: "imessage",
|
||||||
...(await deps.sendIMessage(to, text, {
|
...(await deps.sendIMessage(to, text, {
|
||||||
maxBytes: imessageMaxBytes,
|
maxBytes: imessageMaxBytes,
|
||||||
accountId,
|
accountId: rawAccountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
sendMedia: async (caption, mediaUrl) => ({
|
sendMedia: async (caption, mediaUrl) => ({
|
||||||
@@ -199,7 +200,7 @@ function createProviderHandler(params: {
|
|||||||
...(await deps.sendIMessage(to, caption, {
|
...(await deps.sendIMessage(to, caption, {
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
maxBytes: imessageMaxBytes,
|
maxBytes: imessageMaxBytes,
|
||||||
accountId,
|
accountId: rawAccountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -220,7 +221,7 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
onPayload?: (payload: NormalizedOutboundPayload) => void;
|
onPayload?: (payload: NormalizedOutboundPayload) => void;
|
||||||
}): Promise<OutboundDeliveryResult[]> {
|
}): Promise<OutboundDeliveryResult[]> {
|
||||||
const { cfg, provider, to, payloads } = params;
|
const { cfg, provider, to, payloads } = params;
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = params.accountId;
|
||||||
const deps = {
|
const deps = {
|
||||||
sendWhatsApp: params.deps?.sendWhatsApp ?? sendMessageWhatsApp,
|
sendWhatsApp: params.deps?.sendWhatsApp ?? sendMessageWhatsApp,
|
||||||
sendTelegram: params.deps?.sendTelegram ?? sendMessageTelegram,
|
sendTelegram: params.deps?.sendTelegram ?? sendMessageTelegram,
|
||||||
|
|||||||
69
src/telegram/accounts.test.ts
Normal file
69
src/telegram/accounts.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { resolveTelegramAccount } from "./accounts.js";
|
||||||
|
|
||||||
|
describe("resolveTelegramAccount", () => {
|
||||||
|
it("falls back to the first configured account when accountId is omitted", () => {
|
||||||
|
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||||
|
try {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
telegram: { accounts: { work: { botToken: "tok-work" } } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const account = resolveTelegramAccount({ cfg });
|
||||||
|
expect(account.accountId).toBe("work");
|
||||||
|
expect(account.token).toBe("tok-work");
|
||||||
|
expect(account.tokenSource).toBe("config");
|
||||||
|
} finally {
|
||||||
|
if (prevTelegramToken === undefined) {
|
||||||
|
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers TELEGRAM_BOT_TOKEN when accountId is omitted", () => {
|
||||||
|
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
process.env.TELEGRAM_BOT_TOKEN = "tok-env";
|
||||||
|
try {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
telegram: { accounts: { work: { botToken: "tok-work" } } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const account = resolveTelegramAccount({ cfg });
|
||||||
|
expect(account.accountId).toBe("default");
|
||||||
|
expect(account.token).toBe("tok-env");
|
||||||
|
expect(account.tokenSource).toBe("env");
|
||||||
|
} finally {
|
||||||
|
if (prevTelegramToken === undefined) {
|
||||||
|
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fall back when accountId is explicitly provided", () => {
|
||||||
|
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||||
|
try {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
telegram: { accounts: { work: { botToken: "tok-work" } } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const account = resolveTelegramAccount({ cfg, accountId: "default" });
|
||||||
|
expect(account.accountId).toBe("default");
|
||||||
|
expect(account.tokenSource).toBe("none");
|
||||||
|
expect(account.token).toBe("");
|
||||||
|
} finally {
|
||||||
|
if (prevTelegramToken === undefined) {
|
||||||
|
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,20 +56,37 @@ export function resolveTelegramAccount(params: {
|
|||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
}): ResolvedTelegramAccount {
|
}): ResolvedTelegramAccount {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const hasExplicitAccountId = Boolean(params.accountId?.trim());
|
||||||
const baseEnabled = params.cfg.telegram?.enabled !== false;
|
const baseEnabled = params.cfg.telegram?.enabled !== false;
|
||||||
const merged = mergeTelegramAccountConfig(params.cfg, accountId);
|
|
||||||
const accountEnabled = merged.enabled !== false;
|
const resolve = (accountId: string) => {
|
||||||
const enabled = baseEnabled && accountEnabled;
|
const merged = mergeTelegramAccountConfig(params.cfg, accountId);
|
||||||
const tokenResolution = resolveTelegramToken(params.cfg, { accountId });
|
const accountEnabled = merged.enabled !== false;
|
||||||
return {
|
const enabled = baseEnabled && accountEnabled;
|
||||||
accountId,
|
const tokenResolution = resolveTelegramToken(params.cfg, { accountId });
|
||||||
enabled,
|
return {
|
||||||
name: merged.name?.trim() || undefined,
|
accountId,
|
||||||
token: tokenResolution.token,
|
enabled,
|
||||||
tokenSource: tokenResolution.source,
|
name: merged.name?.trim() || undefined,
|
||||||
config: merged,
|
token: tokenResolution.token,
|
||||||
|
tokenSource: tokenResolution.source,
|
||||||
|
config: merged,
|
||||||
|
} satisfies ResolvedTelegramAccount;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalized = normalizeAccountId(params.accountId);
|
||||||
|
const primary = resolve(normalized);
|
||||||
|
if (hasExplicitAccountId) return primary;
|
||||||
|
if (primary.tokenSource !== "none") return primary;
|
||||||
|
|
||||||
|
// If accountId is omitted, prefer a configured account token over failing on
|
||||||
|
// the implicit "default" account. This keeps env-based setups working (env
|
||||||
|
// still wins) while making config-only tokens work for things like heartbeats.
|
||||||
|
const fallbackId = resolveDefaultTelegramAccountId(params.cfg);
|
||||||
|
if (fallbackId === primary.accountId) return primary;
|
||||||
|
const fallback = resolve(fallbackId);
|
||||||
|
if (fallback.tokenSource === "none") return primary;
|
||||||
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listEnabledTelegramAccounts(
|
export function listEnabledTelegramAccounts(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types";
|
import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types";
|
||||||
|
import type { ApiClientOptions } from "grammy";
|
||||||
import { Bot, InputFile } from "grammy";
|
import { Bot, InputFile } from "grammy";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
@@ -113,10 +114,10 @@ export async function sendMessageTelegram(
|
|||||||
// Use provided api or create a new Bot instance. The nullish coalescing
|
// Use provided api or create a new Bot instance. The nullish coalescing
|
||||||
// operator ensures api is always defined (Bot.api is always non-null).
|
// operator ensures api is always defined (Bot.api is always non-null).
|
||||||
const fetchImpl = resolveTelegramFetch();
|
const fetchImpl = resolveTelegramFetch();
|
||||||
const api =
|
const client: ApiClientOptions | undefined = fetchImpl
|
||||||
opts.api ??
|
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
|
||||||
new Bot(token, fetchImpl ? { client: { fetch: fetchImpl } } : undefined)
|
: undefined;
|
||||||
.api;
|
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||||
const mediaUrl = opts.mediaUrl?.trim();
|
const mediaUrl = opts.mediaUrl?.trim();
|
||||||
|
|
||||||
// Build optional params for forum topics and reply threading.
|
// Build optional params for forum topics and reply threading.
|
||||||
@@ -271,10 +272,10 @@ export async function reactMessageTelegram(
|
|||||||
const chatId = normalizeChatId(String(chatIdInput));
|
const chatId = normalizeChatId(String(chatIdInput));
|
||||||
const messageId = normalizeMessageId(messageIdInput);
|
const messageId = normalizeMessageId(messageIdInput);
|
||||||
const fetchImpl = resolveTelegramFetch();
|
const fetchImpl = resolveTelegramFetch();
|
||||||
const api =
|
const client: ApiClientOptions | undefined = fetchImpl
|
||||||
opts.api ??
|
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
|
||||||
new Bot(token, fetchImpl ? { client: { fetch: fetchImpl } } : undefined)
|
: undefined;
|
||||||
.api;
|
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||||
const request = createTelegramRetryRunner({
|
const request = createTelegramRetryRunner({
|
||||||
retry: opts.retry,
|
retry: opts.retry,
|
||||||
configRetry: account.config.retry,
|
configRetry: account.config.retry,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ApiClientOptions } from "grammy";
|
||||||
import { Bot } from "grammy";
|
import { Bot } from "grammy";
|
||||||
import { resolveTelegramFetch } from "./fetch.js";
|
import { resolveTelegramFetch } from "./fetch.js";
|
||||||
|
|
||||||
@@ -8,10 +9,10 @@ export async function setTelegramWebhook(opts: {
|
|||||||
dropPendingUpdates?: boolean;
|
dropPendingUpdates?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const fetchImpl = resolveTelegramFetch();
|
const fetchImpl = resolveTelegramFetch();
|
||||||
const bot = new Bot(
|
const client: ApiClientOptions | undefined = fetchImpl
|
||||||
opts.token,
|
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
|
||||||
fetchImpl ? { client: { fetch: fetchImpl } } : undefined,
|
: undefined;
|
||||||
);
|
const bot = new Bot(opts.token, client ? { client } : undefined);
|
||||||
await bot.api.setWebhook(opts.url, {
|
await bot.api.setWebhook(opts.url, {
|
||||||
secret_token: opts.secret,
|
secret_token: opts.secret,
|
||||||
drop_pending_updates: opts.dropPendingUpdates ?? false,
|
drop_pending_updates: opts.dropPendingUpdates ?? false,
|
||||||
@@ -20,9 +21,9 @@ export async function setTelegramWebhook(opts: {
|
|||||||
|
|
||||||
export async function deleteTelegramWebhook(opts: { token: string }) {
|
export async function deleteTelegramWebhook(opts: { token: string }) {
|
||||||
const fetchImpl = resolveTelegramFetch();
|
const fetchImpl = resolveTelegramFetch();
|
||||||
const bot = new Bot(
|
const client: ApiClientOptions | undefined = fetchImpl
|
||||||
opts.token,
|
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
|
||||||
fetchImpl ? { client: { fetch: fetchImpl } } : undefined,
|
: undefined;
|
||||||
);
|
const bot = new Bot(opts.token, client ? { client } : undefined);
|
||||||
await bot.api.deleteWebhook();
|
await bot.api.deleteWebhook();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
saveSkillApiKey,
|
saveSkillApiKey,
|
||||||
updateSkillEdit,
|
updateSkillEdit,
|
||||||
updateSkillEnabled,
|
updateSkillEnabled,
|
||||||
|
type SkillMessage,
|
||||||
} from "./controllers/skills";
|
} from "./controllers/skills";
|
||||||
import { loadNodes } from "./controllers/nodes";
|
import { loadNodes } from "./controllers/nodes";
|
||||||
import { loadChatHistory } from "./controllers/chat";
|
import { loadChatHistory } from "./controllers/chat";
|
||||||
@@ -166,6 +167,7 @@ export type AppViewState = {
|
|||||||
skillsError: string | null;
|
skillsError: string | null;
|
||||||
skillsFilter: string;
|
skillsFilter: string;
|
||||||
skillEdits: Record<string, string>;
|
skillEdits: Record<string, string>;
|
||||||
|
skillMessages: Record<string, SkillMessage>;
|
||||||
skillsBusyKey: string | null;
|
skillsBusyKey: string | null;
|
||||||
debugLoading: boolean;
|
debugLoading: boolean;
|
||||||
debugStatus: StatusSummary | null;
|
debugStatus: StatusSummary | null;
|
||||||
@@ -391,13 +393,15 @@ export function renderApp(state: AppViewState) {
|
|||||||
error: state.skillsError,
|
error: state.skillsError,
|
||||||
filter: state.skillsFilter,
|
filter: state.skillsFilter,
|
||||||
edits: state.skillEdits,
|
edits: state.skillEdits,
|
||||||
|
messages: state.skillMessages,
|
||||||
busyKey: state.skillsBusyKey,
|
busyKey: state.skillsBusyKey,
|
||||||
onFilterChange: (next) => (state.skillsFilter = next),
|
onFilterChange: (next) => (state.skillsFilter = next),
|
||||||
onRefresh: () => loadSkills(state),
|
onRefresh: () => loadSkills(state, { clearMessages: true }),
|
||||||
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
|
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
|
||||||
onEdit: (key, value) => updateSkillEdit(state, key, value),
|
onEdit: (key, value) => updateSkillEdit(state, key, value),
|
||||||
onSaveKey: (key) => saveSkillApiKey(state, key),
|
onSaveKey: (key) => saveSkillApiKey(state, key),
|
||||||
onInstall: (name, installId) => installSkill(state, name, installId),
|
onInstall: (skillKey, name, installId) =>
|
||||||
|
installSkill(state, skillKey, name, installId),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import {
|
|||||||
} from "./controllers/cron";
|
} from "./controllers/cron";
|
||||||
import {
|
import {
|
||||||
loadSkills,
|
loadSkills,
|
||||||
|
type SkillMessage,
|
||||||
} from "./controllers/skills";
|
} from "./controllers/skills";
|
||||||
import { loadDebug } from "./controllers/debug";
|
import { loadDebug } from "./controllers/debug";
|
||||||
import { loadLogs } from "./controllers/logs";
|
import { loadLogs } from "./controllers/logs";
|
||||||
@@ -356,6 +357,7 @@ export class ClawdbotApp extends LitElement {
|
|||||||
@state() skillsFilter = "";
|
@state() skillsFilter = "";
|
||||||
@state() skillEdits: Record<string, string> = {};
|
@state() skillEdits: Record<string, string> = {};
|
||||||
@state() skillsBusyKey: string | null = null;
|
@state() skillsBusyKey: string | null = null;
|
||||||
|
@state() skillMessages: Record<string, SkillMessage> = {};
|
||||||
|
|
||||||
@state() debugLoading = false;
|
@state() debugLoading = false;
|
||||||
@state() debugStatus: StatusSummary | null = null;
|
@state() debugStatus: StatusSummary | null = null;
|
||||||
|
|||||||
@@ -9,9 +9,37 @@ export type SkillsState = {
|
|||||||
skillsError: string | null;
|
skillsError: string | null;
|
||||||
skillsBusyKey: string | null;
|
skillsBusyKey: string | null;
|
||||||
skillEdits: Record<string, string>;
|
skillEdits: Record<string, string>;
|
||||||
|
skillMessages: SkillMessageMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadSkills(state: SkillsState) {
|
export type SkillMessage = {
|
||||||
|
kind: "success" | "error";
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkillMessageMap = Record<string, SkillMessage>;
|
||||||
|
|
||||||
|
type LoadSkillsOptions = {
|
||||||
|
clearMessages?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function setSkillMessage(state: SkillsState, key: string, message?: SkillMessage) {
|
||||||
|
if (!key.trim()) return;
|
||||||
|
const next = { ...state.skillMessages };
|
||||||
|
if (message) next[key] = message;
|
||||||
|
else delete next[key];
|
||||||
|
state.skillMessages = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(err: unknown) {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSkills(state: SkillsState, options?: LoadSkillsOptions) {
|
||||||
|
if (options?.clearMessages && Object.keys(state.skillMessages).length > 0) {
|
||||||
|
state.skillMessages = {};
|
||||||
|
}
|
||||||
if (!state.client || !state.connected) return;
|
if (!state.client || !state.connected) return;
|
||||||
if (state.skillsLoading) return;
|
if (state.skillsLoading) return;
|
||||||
state.skillsLoading = true;
|
state.skillsLoading = true;
|
||||||
@@ -22,7 +50,7 @@ export async function loadSkills(state: SkillsState) {
|
|||||||
| undefined;
|
| undefined;
|
||||||
if (res) state.skillsReport = res;
|
if (res) state.skillsReport = res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
state.skillsError = String(err);
|
state.skillsError = getErrorMessage(err);
|
||||||
} finally {
|
} finally {
|
||||||
state.skillsLoading = false;
|
state.skillsLoading = false;
|
||||||
}
|
}
|
||||||
@@ -47,8 +75,17 @@ export async function updateSkillEnabled(
|
|||||||
try {
|
try {
|
||||||
await state.client.request("skills.update", { skillKey, enabled });
|
await state.client.request("skills.update", { skillKey, enabled });
|
||||||
await loadSkills(state);
|
await loadSkills(state);
|
||||||
|
setSkillMessage(state, skillKey, {
|
||||||
|
kind: "success",
|
||||||
|
message: enabled ? "Skill enabled" : "Skill disabled",
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
state.skillsError = String(err);
|
const message = getErrorMessage(err);
|
||||||
|
state.skillsError = message;
|
||||||
|
setSkillMessage(state, skillKey, {
|
||||||
|
kind: "error",
|
||||||
|
message,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
state.skillsBusyKey = null;
|
state.skillsBusyKey = null;
|
||||||
}
|
}
|
||||||
@@ -62,8 +99,17 @@ export async function saveSkillApiKey(state: SkillsState, skillKey: string) {
|
|||||||
const apiKey = state.skillEdits[skillKey] ?? "";
|
const apiKey = state.skillEdits[skillKey] ?? "";
|
||||||
await state.client.request("skills.update", { skillKey, apiKey });
|
await state.client.request("skills.update", { skillKey, apiKey });
|
||||||
await loadSkills(state);
|
await loadSkills(state);
|
||||||
|
setSkillMessage(state, skillKey, {
|
||||||
|
kind: "success",
|
||||||
|
message: "API key saved",
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
state.skillsError = String(err);
|
const message = getErrorMessage(err);
|
||||||
|
state.skillsError = message;
|
||||||
|
setSkillMessage(state, skillKey, {
|
||||||
|
kind: "error",
|
||||||
|
message,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
state.skillsBusyKey = null;
|
state.skillsBusyKey = null;
|
||||||
}
|
}
|
||||||
@@ -71,23 +117,32 @@ export async function saveSkillApiKey(state: SkillsState, skillKey: string) {
|
|||||||
|
|
||||||
export async function installSkill(
|
export async function installSkill(
|
||||||
state: SkillsState,
|
state: SkillsState,
|
||||||
|
skillKey: string,
|
||||||
name: string,
|
name: string,
|
||||||
installId: string,
|
installId: string,
|
||||||
) {
|
) {
|
||||||
if (!state.client || !state.connected) return;
|
if (!state.client || !state.connected) return;
|
||||||
state.skillsBusyKey = name;
|
state.skillsBusyKey = skillKey;
|
||||||
state.skillsError = null;
|
state.skillsError = null;
|
||||||
try {
|
try {
|
||||||
await state.client.request("skills.install", {
|
const result = (await state.client.request("skills.install", {
|
||||||
name,
|
name,
|
||||||
installId,
|
installId,
|
||||||
timeoutMs: 120000,
|
timeoutMs: 120000,
|
||||||
});
|
})) as { ok?: boolean; message?: string };
|
||||||
await loadSkills(state);
|
await loadSkills(state);
|
||||||
|
setSkillMessage(state, skillKey, {
|
||||||
|
kind: "success",
|
||||||
|
message: result?.message ?? "Installed",
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
state.skillsError = String(err);
|
const message = getErrorMessage(err);
|
||||||
|
state.skillsError = message;
|
||||||
|
setSkillMessage(state, skillKey, {
|
||||||
|
kind: "error",
|
||||||
|
message,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
state.skillsBusyKey = null;
|
state.skillsBusyKey = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
|
|||||||
|
|
||||||
import { clampText } from "../format";
|
import { clampText } from "../format";
|
||||||
import type { SkillStatusEntry, SkillStatusReport } from "../types";
|
import type { SkillStatusEntry, SkillStatusReport } from "../types";
|
||||||
|
import type { SkillMessageMap } from "../controllers/skills";
|
||||||
|
|
||||||
export type SkillsProps = {
|
export type SkillsProps = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -10,12 +11,13 @@ export type SkillsProps = {
|
|||||||
filter: string;
|
filter: string;
|
||||||
edits: Record<string, string>;
|
edits: Record<string, string>;
|
||||||
busyKey: string | null;
|
busyKey: string | null;
|
||||||
|
messages: SkillMessageMap;
|
||||||
onFilterChange: (next: string) => void;
|
onFilterChange: (next: string) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onToggle: (skillKey: string, enabled: boolean) => void;
|
onToggle: (skillKey: string, enabled: boolean) => void;
|
||||||
onEdit: (skillKey: string, value: string) => void;
|
onEdit: (skillKey: string, value: string) => void;
|
||||||
onSaveKey: (skillKey: string) => void;
|
onSaveKey: (skillKey: string) => void;
|
||||||
onInstall: (name: string, installId: string) => void;
|
onInstall: (skillKey: string, name: string, installId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function renderSkills(props: SkillsProps) {
|
export function renderSkills(props: SkillsProps) {
|
||||||
@@ -71,8 +73,11 @@ export function renderSkills(props: SkillsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
|
function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
|
||||||
const busy = props.busyKey === skill.skillKey || props.busyKey === skill.name;
|
const busy = props.busyKey === skill.skillKey;
|
||||||
const apiKey = props.edits[skill.skillKey] ?? "";
|
const apiKey = props.edits[skill.skillKey] ?? "";
|
||||||
|
const message = props.messages[skill.skillKey] ?? null;
|
||||||
|
const canInstall =
|
||||||
|
skill.install.length > 0 && skill.missing.bins.length > 0;
|
||||||
const missing = [
|
const missing = [
|
||||||
...skill.missing.bins.map((b) => `bin:${b}`),
|
...skill.missing.bins.map((b) => `bin:${b}`),
|
||||||
...skill.missing.env.map((e) => `env:${e}`),
|
...skill.missing.env.map((e) => `env:${e}`),
|
||||||
@@ -120,16 +125,29 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
|
|||||||
>
|
>
|
||||||
${skill.disabled ? "Enable" : "Disable"}
|
${skill.disabled ? "Enable" : "Disable"}
|
||||||
</button>
|
</button>
|
||||||
${skill.install.length > 0
|
${canInstall
|
||||||
? html`<button
|
? html`<button
|
||||||
class="btn"
|
class="btn"
|
||||||
?disabled=${busy}
|
?disabled=${busy}
|
||||||
@click=${() => props.onInstall(skill.name, skill.install[0].id)}
|
@click=${() =>
|
||||||
|
props.onInstall(skill.skillKey, skill.name, skill.install[0].id)}
|
||||||
>
|
>
|
||||||
${skill.install[0].label}
|
${busy ? "Installing…" : skill.install[0].label}
|
||||||
</button>`
|
</button>`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
|
${message
|
||||||
|
? html`<div
|
||||||
|
class="muted"
|
||||||
|
style="margin-top: 8px; color: ${
|
||||||
|
message.kind === "error"
|
||||||
|
? "var(--danger-color, #d14343)"
|
||||||
|
: "var(--success-color, #0a7f5a)"
|
||||||
|
};"
|
||||||
|
>
|
||||||
|
${message.message}
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
${skill.primaryEnv
|
${skill.primaryEnv
|
||||||
? html`
|
? html`
|
||||||
<div class="field" style="margin-top: 10px;">
|
<div class="field" style="margin-top: 10px;">
|
||||||
|
|||||||
Reference in New Issue
Block a user