chore: fix code formatting

This commit is contained in:
Buns Enchantress
2026-02-03 05:50:36 -06:00
parent 66ab70190b
commit 6721f5b0b7
963 changed files with 35240 additions and 8588 deletions

View File

@@ -32,7 +32,9 @@ export default function (pi: ExtensionAPI) {
}
// Get changed files from git status
const result = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd });
const result = await pi.exec("git", ["status", "--porcelain"], {
cwd: ctx.cwd,
});
if (result.code !== 0) {
ctx.ui.notify(`git status failed: ${result.stderr}`, "error");
@@ -103,7 +105,8 @@ export default function (pi: ExtensionAPI) {
await pi.exec("code", ["-g", fileInfo.file], { cwd: ctx.cwd });
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const message =
error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Failed to open ${fileInfo.file}: ${message}`, "error");
}
};
@@ -113,10 +116,18 @@ export default function (pi: ExtensionAPI) {
const container = new Container();
// Top border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
container.addChild(
new DynamicBorder((s: string) => theme.fg("accent", s)),
);
// Title
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to diff")), 0, 0));
container.addChild(
new Text(
theme.fg("accent", theme.bold(" Select file to diff")),
0,
0,
),
);
// Build select items with colored status
const items: SelectItem[] = files.map((f) => {
@@ -164,11 +175,17 @@ export default function (pi: ExtensionAPI) {
// Help text
container.addChild(
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
new Text(
theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"),
0,
0,
),
);
// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
container.addChild(
new DynamicBorder((s: string) => theme.fg("accent", s)),
);
return {
render: (w) => container.render(w),
@@ -181,7 +198,10 @@ export default function (pi: ExtensionAPI) {
selectList.setSelectedIndex(currentIndex);
} else if (matchesKey(data, Key.right)) {
// Page down - clamp to last
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
currentIndex = Math.min(
items.length - 1,
currentIndex + visibleRows,
);
selectList.setSelectedIndex(currentIndex);
} else {
selectList.handleInput(data);

View File

@@ -37,7 +37,10 @@ export default function (pi: ExtensionAPI) {
const branch = ctx.sessionManager.getBranch();
// First pass: collect tool calls (id -> {path, name}) from assistant messages
const toolCalls = new Map<string, { path: string; name: FileToolName; timestamp: number }>();
const toolCalls = new Map<
string,
{ path: string; name: FileToolName; timestamp: number }
>();
for (const entry of branch) {
if (entry.type !== "message") {
@@ -52,7 +55,11 @@ export default function (pi: ExtensionAPI) {
if (name === "read" || name === "write" || name === "edit") {
const path = block.arguments?.path;
if (path && typeof path === "string") {
toolCalls.set(block.id, { path, name, timestamp: msg.timestamp });
toolCalls.set(block.id, {
path,
name,
timestamp: msg.timestamp,
});
}
}
}
@@ -108,7 +115,8 @@ export default function (pi: ExtensionAPI) {
try {
await pi.exec("code", ["-g", file.path], { cwd: ctx.cwd });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const message =
error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Failed to open ${file.path}: ${message}`, "error");
}
};
@@ -118,10 +126,18 @@ export default function (pi: ExtensionAPI) {
const container = new Container();
// Top border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
container.addChild(
new DynamicBorder((s: string) => theme.fg("accent", s)),
);
// Title
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to open")), 0, 0));
container.addChild(
new Text(
theme.fg("accent", theme.bold(" Select file to open")),
0,
0,
),
);
// Build select items with colored operations
const items: SelectItem[] = files.map((f) => {
@@ -163,11 +179,17 @@ export default function (pi: ExtensionAPI) {
// Help text
container.addChild(
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
new Text(
theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"),
0,
0,
),
);
// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
container.addChild(
new DynamicBorder((s: string) => theme.fg("accent", s)),
);
return {
render: (w) => container.render(w),
@@ -180,7 +202,10 @@ export default function (pi: ExtensionAPI) {
selectList.setSelectedIndex(currentIndex);
} else if (matchesKey(data, Key.right)) {
// Page down - clamp to last
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
currentIndex = Math.min(
items.length - 1,
currentIndex + visibleRows,
);
selectList.setSelectedIndex(currentIndex);
} else {
selectList.handleInput(data);

View File

@@ -5,7 +5,8 @@ import {
} from "@mariozechner/pi-coding-agent";
import { Container, Text } from "@mariozechner/pi-tui";
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
const PR_PROMPT_PATTERN =
/^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
type PromptMatch = {
@@ -82,7 +83,9 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
authorText?: string,
) => {
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
const titleText = title ? thm.fg("accent", title) : thm.fg("accent", match.url);
const titleText = title
? thm.fg("accent", title)
: thm.fg("accent", match.url);
const authorLine = authorText ? thm.fg("muted", authorText) : undefined;
const urlLine = thm.fg("dim", match.url);
@@ -99,11 +102,17 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
});
};
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => {
const applySessionName = (
ctx: ExtensionContext,
match: PromptMatch,
title?: string,
) => {
const label = match.kind === "pr" ? "PR" : "Issue";
const trimmedTitle = title?.trim();
const fallbackName = `${label}: ${match.url}`;
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName;
const desiredName = trimmedTitle
? `${label}: ${trimmedTitle} (${match.url})`
: fallbackName;
const currentName = pi.getSessionName()?.trim();
if (!currentName) {
pi.setSessionName(desiredName);
@@ -137,7 +146,9 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
rebuildFromSession(ctx);
});
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
const getUserText = (
content: string | { type: string; text?: string }[] | undefined,
): string => {
if (!content) {
return "";
}
@@ -146,7 +157,10 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
}
return (
content
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.filter(
(block): block is { type: "text"; text: string } =>
block.type === "text",
)
.map((block) => block.text)
.join("\n") ?? ""
);

View File

@@ -66,7 +66,8 @@ repos:
rev: v1.22.0
hooks:
- id: zizmor
args: [--persona=regular, --min-severity=medium, --min-confidence=medium]
args:
[--persona=regular, --min-severity=medium, --min-confidence=medium]
exclude: "^(vendor/|Swabble/)"
# Project checks (same commands as CI)

View File

@@ -3,9 +3,11 @@
## 0.2.0 — 2025-12-23
### Highlights
- Added `SwabbleKit` (multi-platform wake-word gate utilities with segment-aware gap detection).
- Swabble package now supports iOS + macOS consumers; CLI remains macOS 26-only.
### Changes
- CLI wake-word matching/stripping routed through `SwabbleKit` helpers.
- Speech pipeline types now explicitly gated to macOS 26 / iOS 26 availability.

View File

@@ -10,6 +10,7 @@ swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAn
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).
## Quick start
```bash
# Install deps
brew install swiftformat swiftlint
@@ -31,6 +32,7 @@ swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
```
## Use as a library
Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product:
```swift
@@ -47,6 +49,7 @@ targets: [
```
## CLI
- `serve` — foreground loop (mic → wake → hook)
- `transcribe <file>` — offline transcription (txt|srt)
- `test-hook "text"` — invoke configured hook
@@ -62,11 +65,18 @@ targets: [
All commands accept Commander runtime flags (`-v/--verbose`, `--json-output`, `--log-level`), plus `--config` where applicable.
## Config
`~/.config/swabble/config.json` (auto-created by `setup`):
```json
{
"audio": {"deviceName": "", "deviceIndex": -1, "sampleRate": 16000, "channels": 1},
"wake": {"enabled": true, "word": "clawd", "aliases": ["claude"]},
"audio": {
"deviceName": "",
"deviceIndex": -1,
"sampleRate": 16000,
"channels": 1
},
"wake": { "enabled": true, "word": "clawd", "aliases": ["claude"] },
"hook": {
"command": "",
"args": [],
@@ -76,9 +86,9 @@ All commands accept Commander runtime flags (`-v/--verbose`, `--json-output`, `-
"timeoutSeconds": 5,
"env": {}
},
"logging": {"level": "info", "format": "text"},
"transcripts": {"enabled": true, "maxEntries": 50},
"speech": {"localeIdentifier": "en_US", "etiquetteReplacements": false}
"logging": { "level": "info", "format": "text" },
"transcripts": { "enabled": true, "maxEntries": 50 },
"speech": { "localeIdentifier": "en_US", "etiquetteReplacements": false }
}
```
@@ -86,26 +96,33 @@ All commands accept Commander runtime flags (`-v/--verbose`, `--json-output`, `-
- Transcripts persist to `~/Library/Application Support/swabble/transcripts.log`.
## Hook protocol
When a wake-gated transcript passes min_chars & cooldown, swabble runs:
```
<command> <args...> "<prefix><text>"
```
Environment variables:
- `SWABBLE_TEXT` — stripped transcript (wake word removed)
- `SWABBLE_PREFIX` — rendered prefix (hostname substituted)
- plus any `hook.env` key/values
## Speech pipeline
- `AVAudioEngine` tap → `BufferConverter``AnalyzerInput``SpeechAnalyzer` with a `SpeechTranscriber` module.
- Requests volatile + final results; the CLI uses text-only wake gating today.
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
## Development
- Format: `./scripts/format.sh` (uses local `.swiftformat`)
- Lint: `./scripts/lint.sh` (uses local `.swiftlint.yml`)
- Tests: `swift test` (uses swift-testing package)
## Roadmap
- launchd control (load/bootout, PID + status socket)
- JSON logging + PII redaction toggle
- Stronger wake-word detection and control socket status/health

View File

@@ -3,6 +3,7 @@
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. Shared wake-gate utilities live in `SwabbleKit` for reuse by other apps (iOS/macOS).
## Requirements
- macOS 26+, Swift 6.2, Speech.framework with on-device assets.
- Local only; no network calls during transcription.
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
@@ -15,6 +16,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
- Basic status/health surfaces and mic selection stubs.
## Architecture
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
@@ -24,10 +26,12 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
- **Logging**: simple structured logger to stderr; respects log level.
## Out of scope (initial cut)
- Model management (Speech handles assets).
- Launchd helper (planned follow-up).
- Advanced wake-word detector (segment-aware gate now lives in `SwabbleKit`; CLI still text-only until segment timing is plumbed through).
## Open decisions
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).
- Hook redaction (PII) parity with brabble — placeholder boolean, no implementation yet.

View File

@@ -3,11 +3,13 @@
Modern Android node app: connects to the **Gateway WebSocket** (`_openclaw-gw._tcp`) and exposes **Canvas + Chat + Camera**.
Notes:
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
- Chat always uses the shared session key **`main`** (same session across iOS/macOS/WebChat/Android).
- Supports modern Android only (`minSdk 31`, Kotlin + Jetpack Compose).
## Open in Android Studio
- Open the folder `apps/android`.
## Build / Run
@@ -23,16 +25,19 @@ cd apps/android
## Connect / Pair
1) Start the gateway (on your “master” machine):
1. Start the gateway (on your “master” machine):
```bash
pnpm openclaw gateway --port 18789 --verbose
```
2) In the Android app:
2. In the Android app:
- Open **Settings**
- Either select a discovered gateway under **Discovered Gateways**, or use **Advanced → Manual Gateway** (host + port).
3) Approve pairing (on the gateway machine):
3. Approve pairing (on the gateway machine):
```bash
openclaw nodes pending
openclaw nodes approve <requestId>

View File

@@ -3,11 +3,13 @@
Internal-only SwiftUI app scaffold.
## Lint/format (required)
```bash
brew install swiftformat swiftlint
```
## Generate the Xcode project
```bash
cd apps/ios
xcodegen generate
@@ -15,9 +17,11 @@ open OpenClaw.xcodeproj
```
## Shared packages
- `../shared/OpenClawKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
## fastlane
```bash
brew install fastlane

View File

@@ -1,31 +1,116 @@
{
"images" : [
{ "filename" : "icon-20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" },
{ "filename" : "icon-20@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" },
{ "filename" : "icon-20@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "20x20" },
{ "filename" : "icon-20@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "20x20" },
"images": [
{
"filename": "icon-20@1x.png",
"idiom": "ipad",
"scale": "1x",
"size": "20x20"
},
{
"filename": "icon-20@2x.png",
"idiom": "ipad",
"scale": "2x",
"size": "20x20"
},
{
"filename": "icon-20@2x.png",
"idiom": "iphone",
"scale": "2x",
"size": "20x20"
},
{
"filename": "icon-20@3x.png",
"idiom": "iphone",
"scale": "3x",
"size": "20x20"
},
{ "filename" : "icon-29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" },
{ "filename" : "icon-29@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" },
{ "filename" : "icon-29@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "29x29" },
{ "filename" : "icon-29@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "29x29" },
{
"filename": "icon-29@1x.png",
"idiom": "ipad",
"scale": "1x",
"size": "29x29"
},
{
"filename": "icon-29@2x.png",
"idiom": "ipad",
"scale": "2x",
"size": "29x29"
},
{
"filename": "icon-29@2x.png",
"idiom": "iphone",
"scale": "2x",
"size": "29x29"
},
{
"filename": "icon-29@3x.png",
"idiom": "iphone",
"scale": "3x",
"size": "29x29"
},
{ "filename" : "icon-40@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" },
{ "filename" : "icon-40@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" },
{ "filename" : "icon-40@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "40x40" },
{ "filename" : "icon-40@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "40x40" },
{
"filename": "icon-40@1x.png",
"idiom": "ipad",
"scale": "1x",
"size": "40x40"
},
{
"filename": "icon-40@2x.png",
"idiom": "ipad",
"scale": "2x",
"size": "40x40"
},
{
"filename": "icon-40@2x.png",
"idiom": "iphone",
"scale": "2x",
"size": "40x40"
},
{
"filename": "icon-40@3x.png",
"idiom": "iphone",
"scale": "3x",
"size": "40x40"
},
{ "filename" : "icon-60@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "60x60" },
{ "filename" : "icon-60@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "60x60" },
{
"filename": "icon-60@2x.png",
"idiom": "iphone",
"scale": "2x",
"size": "60x60"
},
{
"filename": "icon-60@3x.png",
"idiom": "iphone",
"scale": "3x",
"size": "60x60"
},
{ "filename" : "icon-76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" },
{
"filename": "icon-76@2x.png",
"idiom": "ipad",
"scale": "2x",
"size": "76x76"
},
{ "filename" : "icon-83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" },
{
"filename": "icon-83.5@2x.png",
"idiom": "ipad",
"scale": "2x",
"size": "83.5x83.5"
},
{ "filename" : "icon-1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" }
{
"filename": "icon-1024.png",
"idiom": "ios-marketing",
"scale": "1x",
"size": "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -1,6 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -1,36 +1,31 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000"
"fill": {
"automatic-gradient": "extended-srgb:0.00000,0.53333,1.00000,1.00000"
},
"groups" : [
"groups": [
{
"layers" : [
"layers": [
{
"image-name" : "openclaw-mac.png",
"name" : "openclaw-mac",
"position" : {
"scale" : 1.07,
"translation-in-points" : [
-2,
0
]
"image-name": "openclaw-mac.png",
"name": "openclaw-mac",
"position": {
"scale": 1.07,
"translation-in-points": [-2, 0]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
"shadow": {
"kind": "neutral",
"opacity": 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
"translucency": {
"enabled": true,
"value": 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
"supported-platforms": {
"circles": ["watchOS"],
"squares": "shared"
}
}

View File

@@ -25,12 +25,14 @@ Creates `dist/OpenClaw.app` and signs it via `scripts/codesign-mac-app.sh`.
## Signing behavior
Auto-selects identity (first match):
1) Developer ID Application
2) Apple Distribution
3) Apple Development
4) first available identity
1. Developer ID Application
2. Apple Distribution
3. Apple Development
4. first available identity
If none found:
- errors by default
- set `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` to ad-hoc sign
@@ -40,6 +42,7 @@ After signing, we read the app bundle Team ID and compare every Mach-O inside th
If any embedded binary has a different Team ID, signing fails.
Skip the audit:
```bash
SKIP_TEAM_ID_CHECK=1 scripts/package-mac-app.sh
```

View File

@@ -1,12 +1,6 @@
{
"iMac9,1": [
"iMac (20-inch, Early 2009)",
"iMac (24-inch, Early 2009)"
],
"iMac10,1": [
"iMac (21.5-inch, Late 2009)",
"iMac (27-inch, Late 2009)"
],
"iMac9,1": ["iMac (20-inch, Early 2009)", "iMac (24-inch, Early 2009)"],
"iMac10,1": ["iMac (21.5-inch, Late 2009)", "iMac (27-inch, Late 2009)"],
"iMac11,2": "iMac (21.5-inch, Mid 2010)",
"iMac11,3": "iMac (27-inch, Mid 2010)",
"iMac12,1": "iMac (21.5-inch, Mid 2011)",
@@ -40,10 +34,7 @@
"Mac14,5": "MacBook Pro (14-inch, 2023)",
"Mac14,6": "MacBook Pro (16-inch, 2023)",
"Mac14,7": "MacBook Pro (13-inch, M2, 2022)",
"Mac14,8": [
"Mac Pro (2023)",
"Mac Pro (Rack, 2023)"
],
"Mac14,8": ["Mac Pro (2023)", "Mac Pro (Rack, 2023)"],
"Mac14,9": "MacBook Pro (14-inch, 2023)",
"Mac14,10": "MacBook Pro (16-inch, 2023)",
"Mac14,12": "Mac mini (2023)",
@@ -187,10 +178,7 @@
"MacBookPro18,2": "MacBook Pro (16-inch, 2021)",
"MacBookPro18,3": "MacBook Pro (14-inch, 2021)",
"MacBookPro18,4": "MacBook Pro (14-inch, 2021)",
"Macmini3,1": [
"Mac mini (Early 2009)",
"Mac mini (Late 2009)"
],
"Macmini3,1": ["Mac mini (Early 2009)", "Mac mini (Late 2009)"],
"Macmini4,1": "Mac mini (Mid 2010)",
"Macmini5,1": "Mac mini (Mid 2011)",
"Macmini5,2": "Mac mini (Mid 2011)",
@@ -207,8 +195,5 @@
"Mac Pro Server (Mid 2012)"
],
"MacPro6,1": "Mac Pro (Late 2013)",
"MacPro7,1": [
"Mac Pro (2019)",
"Mac Pro (Rack, 2019)"
]
"MacPro7,1": ["Mac Pro (2019)", "Mac Pro (Rack, 2019)"]
}

View File

@@ -2,54 +2,100 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<title>Canvas</title>
<script>
(() => {
try {
const params = new URLSearchParams(window.location.search);
const platform = (params.get('platform') || '').trim().toLowerCase();
const platform = (params.get("platform") || "").trim().toLowerCase();
if (platform) {
document.documentElement.dataset.platform = platform;
return;
}
if (/android/i.test(navigator.userAgent || '')) {
document.documentElement.dataset.platform = 'android';
if (/android/i.test(navigator.userAgent || "")) {
document.documentElement.dataset.platform = "android";
}
} catch (_) {}
})();
</script>
<style>
:root { color-scheme: dark; }
@media (prefers-reduced-motion: reduce) {
body::before, body::after { animation: none !important; }
:root {
color-scheme: dark;
}
@media (prefers-reduced-motion: reduce) {
body::before,
body::after {
animation: none !important;
}
}
html,
body {
height: 100%;
margin: 0;
}
html,body { height:100%; margin:0; }
body {
background:
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
radial-gradient(
1200px 900px at 15% 20%,
rgba(42, 113, 255, 0.18),
rgba(0, 0, 0, 0) 55%
),
radial-gradient(
900px 700px at 85% 30%,
rgba(255, 0, 138, 0.14),
rgba(0, 0, 0, 0) 60%
),
radial-gradient(
1000px 900px at 60% 90%,
rgba(0, 209, 255, 0.1),
rgba(0, 0, 0, 0) 60%
),
#000;
overflow: hidden;
}
:root[data-platform="android"] body {
background:
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.62), rgba(0,0,0,0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.52), rgba(0,0,0,0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.48), rgba(0,0,0,0) 60%),
radial-gradient(
1200px 900px at 15% 20%,
rgba(42, 113, 255, 0.62),
rgba(0, 0, 0, 0) 55%
),
radial-gradient(
900px 700px at 85% 30%,
rgba(255, 0, 138, 0.52),
rgba(0, 0, 0, 0) 60%
),
radial-gradient(
1000px 900px at 60% 90%,
rgba(0, 209, 255, 0.48),
rgba(0, 0, 0, 0) 60%
),
#0b1328;
}
body::before {
content:"";
content: "";
position: fixed;
inset: -20%;
background:
repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
transparent 1px, transparent 48px),
repeating-linear-gradient(90deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
transparent 1px, transparent 48px);
transform: translate3d(0,0,0) rotate(-7deg);
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.03) 0,
rgba(255, 255, 255, 0.03) 1px,
transparent 1px,
transparent 48px
),
repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.03) 0,
rgba(255, 255, 255, 0.03) 1px,
transparent 1px,
transparent 48px
);
transform: translate3d(0, 0, 0) rotate(-7deg);
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
@@ -57,55 +103,105 @@
pointer-events: none;
animation: openclaw-grid-drift 140s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::before { opacity: 0.80; }
:root[data-platform="android"] body::before {
opacity: 0.8;
}
body::after {
content:"";
content: "";
position: fixed;
inset: -35%;
background:
radial-gradient(900px 700px at 30% 30%, rgba(42,113,255,0.16), rgba(0,0,0,0) 60%),
radial-gradient(800px 650px at 70% 35%, rgba(255,0,138,0.12), rgba(0,0,0,0) 62%),
radial-gradient(900px 800px at 55% 75%, rgba(0,209,255,0.10), rgba(0,0,0,0) 62%);
radial-gradient(
900px 700px at 30% 30%,
rgba(42, 113, 255, 0.16),
rgba(0, 0, 0, 0) 60%
),
radial-gradient(
800px 650px at 70% 35%,
rgba(255, 0, 138, 0.12),
rgba(0, 0, 0, 0) 62%
),
radial-gradient(
900px 800px at 55% 75%,
rgba(0, 209, 255, 0.1),
rgba(0, 0, 0, 0) 62%
);
filter: blur(28px);
opacity: 0.52;
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
transform: translate3d(0,0,0);
transform: translate3d(0, 0, 0);
pointer-events: none;
animation: openclaw-glow-drift 110s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::after { opacity: 0.85; }
:root[data-platform="android"] body::after {
opacity: 0.85;
}
@supports (mix-blend-mode: screen) {
body::after { mix-blend-mode: screen; }
body::after {
mix-blend-mode: screen;
}
}
@supports not (mix-blend-mode: screen) {
body::after { opacity: 0.70; }
body::after {
opacity: 0.7;
}
}
@keyframes openclaw-grid-drift {
0% { transform: translate3d(-12px, 8px, 0) rotate(-7deg); opacity: 0.40; }
50% { transform: translate3d( 10px,-7px, 0) rotate(-6.6deg); opacity: 0.56; }
100% { transform: translate3d(-8px, 6px, 0) rotate(-7.2deg); opacity: 0.42; }
0% {
transform: translate3d(-12px, 8px, 0) rotate(-7deg);
opacity: 0.4;
}
50% {
transform: translate3d(10px, -7px, 0) rotate(-6.6deg);
opacity: 0.56;
}
100% {
transform: translate3d(-8px, 6px, 0) rotate(-7.2deg);
opacity: 0.42;
}
}
@keyframes openclaw-glow-drift {
0% { transform: translate3d(-18px, 12px, 0) scale(1.02); opacity: 0.40; }
50% { transform: translate3d( 14px,-10px, 0) scale(1.05); opacity: 0.52; }
100% { transform: translate3d(-10px, 8px, 0) scale(1.03); opacity: 0.43; }
0% {
transform: translate3d(-18px, 12px, 0) scale(1.02);
opacity: 0.4;
}
50% {
transform: translate3d(14px, -10px, 0) scale(1.05);
opacity: 0.52;
}
100% {
transform: translate3d(-10px, 8px, 0) scale(1.03);
opacity: 0.43;
}
}
canvas {
position: fixed;
inset: 0;
display:block;
width:100vw;
height:100vh;
display: block;
width: 100vw;
height: 100vh;
touch-action: none;
z-index: 1;
}
:root[data-platform="android"] #openclaw-canvas {
background:
radial-gradient(1100px 800px at 20% 15%, rgba(42, 113, 255, 0.78), rgba(0,0,0,0) 58%),
radial-gradient(900px 650px at 82% 28%, rgba(255, 0, 138, 0.66), rgba(0,0,0,0) 62%),
radial-gradient(1000px 900px at 60% 88%, rgba(0, 209, 255, 0.58), rgba(0,0,0,0) 62%),
radial-gradient(
1100px 800px at 20% 15%,
rgba(42, 113, 255, 0.78),
rgba(0, 0, 0, 0) 58%
),
radial-gradient(
900px 650px at 82% 28%,
rgba(255, 0, 138, 0.66),
rgba(0, 0, 0, 0) 62%
),
radial-gradient(
1000px 900px at 60% 88%,
rgba(0, 209, 255, 0.58),
rgba(0, 0, 0, 0) 62%
),
#141c33;
}
#openclaw-status {
@@ -124,21 +220,32 @@
padding: 16px 18px;
border-radius: 14px;
background: rgba(18, 18, 22, 0.42);
border: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.55);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
}
#openclaw-status .title {
font: 600 20px -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
font:
600 20px -apple-system,
BlinkMacSystemFont,
"SF Pro Display",
"SF Pro Text",
system-ui,
sans-serif;
letter-spacing: 0.2px;
color: rgba(255,255,255,0.92);
color: rgba(255, 255, 255, 0.92);
text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
}
#openclaw-status .subtitle {
margin-top: 6px;
font: 500 12px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
color: rgba(255,255,255,0.58);
font:
500 12px -apple-system,
BlinkMacSystemFont,
"SF Pro Text",
system-ui,
sans-serif;
color: rgba(255, 255, 255, 0.58);
}
</style>
</head>
@@ -147,23 +254,29 @@
<div id="openclaw-status">
<div class="card">
<div class="title" id="openclaw-status-title">Ready</div>
<div class="subtitle" id="openclaw-status-subtitle">Waiting for agent</div>
<div class="subtitle" id="openclaw-status-subtitle">
Waiting for agent
</div>
</div>
</div>
<script>
(() => {
const canvas = document.getElementById('openclaw-canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('openclaw-status');
const titleEl = document.getElementById('openclaw-status-title');
const subtitleEl = document.getElementById('openclaw-status-subtitle');
const canvas = document.getElementById("openclaw-canvas");
const ctx = canvas.getContext("2d");
const statusEl = document.getElementById("openclaw-status");
const titleEl = document.getElementById("openclaw-status-title");
const subtitleEl = document.getElementById("openclaw-status-subtitle");
const debugStatusEnabledByQuery = (() => {
try {
const params = new URLSearchParams(window.location.search);
const raw = params.get('debugStatus') ?? params.get('debug');
const raw = params.get("debugStatus") ?? params.get("debug");
if (!raw) return false;
const normalized = String(raw).trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes';
return (
normalized === "1" ||
normalized === "true" ||
normalized === "yes"
);
} catch (_) {
return false;
}
@@ -179,19 +292,19 @@
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
window.addEventListener("resize", resize);
resize();
const setDebugStatusEnabled = (enabled) => {
debugStatusEnabled = !!enabled;
if (!statusEl) return;
if (!debugStatusEnabled) {
statusEl.style.display = 'none';
statusEl.style.display = "none";
}
};
if (statusEl && !debugStatusEnabled) {
statusEl.style.display = 'none';
statusEl.style.display = "none";
}
const api = {
@@ -201,25 +314,26 @@
setStatus: (title, subtitle) => {
if (!statusEl || !debugStatusEnabled) return;
if (!title && !subtitle) {
statusEl.style.display = 'none';
statusEl.style.display = "none";
return;
}
statusEl.style.display = 'flex';
if (titleEl && typeof title === 'string') titleEl.textContent = title;
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
statusEl.style.display = "flex";
if (titleEl && typeof title === "string")
titleEl.textContent = title;
if (subtitleEl && typeof subtitle === "string")
subtitleEl.textContent = subtitle;
if (!debugStatusEnabled) {
clearTimeout(window.__statusTimeout);
window.__statusTimeout = setTimeout(() => {
statusEl.style.display = 'none';
statusEl.style.display = "none";
}, 3000);
} else {
clearTimeout(window.__statusTimeout);
}
}
},
};
window.__openclaw = api;
})();
</script>
</body>
</html>

View File

@@ -90,7 +90,13 @@
},
"act": {
"label": "act",
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
"detailKeys": [
"request.kind",
"request.ref",
"request.selector",
"request.text",
"request.value"
]
}
}
},
@@ -98,13 +104,31 @@
"emoji": "🖼️",
"title": "Canvas",
"actions": {
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
"present": {
"label": "present",
"detailKeys": ["target", "node", "nodeId"]
},
"hide": { "label": "hide", "detailKeys": ["node", "nodeId"] },
"navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] },
"eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] },
"snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] },
"a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] },
"a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] }
"navigate": {
"label": "navigate",
"detailKeys": ["url", "node", "nodeId"]
},
"eval": {
"label": "eval",
"detailKeys": ["javaScript", "node", "nodeId"]
},
"snapshot": {
"label": "snapshot",
"detailKeys": ["format", "node", "nodeId"]
},
"a2ui_push": {
"label": "A2UI push",
"detailKeys": ["jsonlPath", "node", "nodeId"]
},
"a2ui_reset": {
"label": "A2UI reset",
"detailKeys": ["node", "nodeId"]
}
}
},
"nodes": {
@@ -116,13 +140,32 @@
"pending": { "label": "pending" },
"approve": { "label": "approve", "detailKeys": ["requestId"] },
"reject": { "label": "reject", "detailKeys": ["requestId"] },
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
"notify": {
"label": "notify",
"detailKeys": ["node", "nodeId", "title", "body"]
},
"camera_snap": {
"label": "camera snap",
"detailKeys": ["node", "nodeId", "facing", "deviceId"]
},
"camera_list": {
"label": "camera list",
"detailKeys": ["node", "nodeId"]
},
"camera_clip": {
"label": "camera clip",
"detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"]
},
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
"detailKeys": [
"node",
"nodeId",
"duration",
"durationMs",
"fps",
"screenIndex"
]
}
}
},
@@ -162,32 +205,80 @@
"emoji": "💬",
"title": "Discord",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"react": {
"label": "react",
"detailKeys": ["channelId", "messageId", "emoji"]
},
"reactions": {
"label": "reactions",
"detailKeys": ["channelId", "messageId"]
},
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"readMessages": {
"label": "read messages",
"detailKeys": ["channelId", "limit"]
},
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"editMessage": {
"label": "edit",
"detailKeys": ["channelId", "messageId"]
},
"deleteMessage": {
"label": "delete",
"detailKeys": ["channelId", "messageId"]
},
"threadCreate": {
"label": "thread create",
"detailKeys": ["channelId", "name"]
},
"threadList": {
"label": "thread list",
"detailKeys": ["guildId", "channelId"]
},
"threadReply": {
"label": "thread reply",
"detailKeys": ["channelId", "content"]
},
"pinMessage": {
"label": "pin",
"detailKeys": ["channelId", "messageId"]
},
"unpinMessage": {
"label": "unpin",
"detailKeys": ["channelId", "messageId"]
},
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"searchMessages": {
"label": "search",
"detailKeys": ["guildId", "content"]
},
"memberInfo": {
"label": "member",
"detailKeys": ["guildId", "userId"]
},
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"roleAdd": {
"label": "role add",
"detailKeys": ["guildId", "userId", "roleId"]
},
"roleRemove": {
"label": "role remove",
"detailKeys": ["guildId", "userId", "roleId"]
},
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
"voiceStatus": {
"label": "voice",
"detailKeys": ["guildId", "userId"]
},
"eventList": { "label": "events", "detailKeys": ["guildId"] },
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
"eventCreate": {
"label": "event create",
"detailKeys": ["guildId", "name"]
},
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }

View File

@@ -33,12 +33,26 @@ if (modalElement && Array.isArray(modalElement.styles)) {
}
const emptyClasses = () => ({});
const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} });
const textHintStyles = () => ({
h1: {},
h2: {},
h3: {},
h4: {},
h5: {},
body: {},
caption: {},
});
const isAndroid = /Android/i.test(globalThis.navigator?.userAgent ?? "");
const cardShadow = isAndroid ? "0 2px 10px rgba(0,0,0,.18)" : "0 10px 30px rgba(0,0,0,.35)";
const buttonShadow = isAndroid ? "0 2px 10px rgba(6, 182, 212, 0.14)" : "0 10px 25px rgba(6, 182, 212, 0.18)";
const statusShadow = isAndroid ? "0 2px 10px rgba(0, 0, 0, 0.18)" : "0 10px 24px rgba(0, 0, 0, 0.25)";
const cardShadow = isAndroid
? "0 2px 10px rgba(0,0,0,.18)"
: "0 10px 30px rgba(0,0,0,.35)";
const buttonShadow = isAndroid
? "0 2px 10px rgba(6, 182, 212, 0.14)"
: "0 10px 25px rgba(6, 182, 212, 0.18)";
const statusShadow = isAndroid
? "0 2px 10px rgba(0, 0, 0, 0.18)"
: "0 10px 24px rgba(0, 0, 0, 0.25)";
const statusBlur = isAndroid ? "10px" : "14px";
const openclawTheme = {
@@ -47,8 +61,16 @@ const openclawTheme = {
Button: emptyClasses(),
Card: emptyClasses(),
Column: emptyClasses(),
CheckBox: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
DateTimeInput: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
CheckBox: {
container: emptyClasses(),
element: emptyClasses(),
label: emptyClasses(),
},
DateTimeInput: {
container: emptyClasses(),
element: emptyClasses(),
label: emptyClasses(),
},
Divider: emptyClasses(),
Image: {
all: emptyClasses(),
@@ -62,10 +84,22 @@ const openclawTheme = {
Icon: emptyClasses(),
List: emptyClasses(),
Modal: { backdrop: emptyClasses(), element: emptyClasses() },
MultipleChoice: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
MultipleChoice: {
container: emptyClasses(),
element: emptyClasses(),
label: emptyClasses(),
},
Row: emptyClasses(),
Slider: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
Tabs: { container: emptyClasses(), element: emptyClasses(), controls: { all: emptyClasses(), selected: emptyClasses() } },
Slider: {
container: emptyClasses(),
element: emptyClasses(),
label: emptyClasses(),
},
Tabs: {
container: emptyClasses(),
element: emptyClasses(),
controls: { all: emptyClasses(), selected: emptyClasses() },
},
Text: {
all: emptyClasses(),
h1: emptyClasses(),
@@ -76,7 +110,11 @@ const openclawTheme = {
caption: emptyClasses(),
body: emptyClasses(),
},
TextField: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
TextField: {
container: emptyClasses(),
element: emptyClasses(),
label: emptyClasses(),
},
Video: emptyClasses(),
},
elements: {
@@ -112,7 +150,8 @@ const openclawTheme = {
},
additionalStyles: {
Card: {
background: "linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03))",
background:
"linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03))",
border: "1px solid rgba(255,255,255,.09)",
borderRadius: "14px",
padding: "14px",
@@ -175,8 +214,7 @@ class OpenClawA2UIHost extends LitElement {
height: 100%;
position: relative;
box-sizing: border-box;
padding:
var(--openclaw-a2ui-inset-top, 0px)
padding: var(--openclaw-a2ui-inset-top, 0px)
var(--openclaw-a2ui-inset-right, 0px)
var(--openclaw-a2ui-inset-bottom, 0px)
var(--openclaw-a2ui-inset-left, 0px);
@@ -204,7 +242,12 @@ class OpenClawA2UIHost extends LitElement {
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.92);
font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
font:
13px/1.2 system-ui,
-apple-system,
BlinkMacSystemFont,
"Roboto",
sans-serif;
pointer-events: none;
backdrop-filter: blur(${unsafeCSS(statusBlur)});
-webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)});
@@ -225,7 +268,12 @@ class OpenClawA2UIHost extends LitElement {
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.92);
font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
font:
13px/1.2 system-ui,
-apple-system,
BlinkMacSystemFont,
"Roboto",
sans-serif;
pointer-events: none;
backdrop-filter: blur(${unsafeCSS(statusBlur)});
-webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)});
@@ -242,7 +290,10 @@ class OpenClawA2UIHost extends LitElement {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: var(--openclaw-a2ui-empty-top, var(--openclaw-a2ui-status-top, 12px));
top: var(
--openclaw-a2ui-empty-top,
var(--openclaw-a2ui-status-top, 12px)
);
text-align: center;
opacity: 0.8;
padding: 10px 12px;
@@ -300,7 +351,10 @@ class OpenClawA2UIHost extends LitElement {
}
#makeActionId() {
return globalThis.crypto?.randomUUID?.() ?? `a2ui_${Date.now()}_${Math.random().toString(16).slice(2)}`;
return (
globalThis.crypto?.randomUUID?.() ??
`a2ui_${Date.now()}_${Math.random().toString(16).slice(2)}`
);
}
#setToast(text, kind = "ok", timeoutMs = 1400) {
@@ -317,14 +371,29 @@ class OpenClawA2UIHost extends LitElement {
#handleActionStatus(evt) {
const detail = evt?.detail ?? null;
if (!detail || typeof detail.id !== "string") {return;}
if (!this.pendingAction || this.pendingAction.id !== detail.id) {return;}
if (!detail || typeof detail.id !== "string") {
return;
}
if (!this.pendingAction || this.pendingAction.id !== detail.id) {
return;
}
if (detail.ok) {
this.pendingAction = { ...this.pendingAction, phase: "sent", sentAt: Date.now() };
this.pendingAction = {
...this.pendingAction,
phase: "sent",
sentAt: Date.now(),
};
} else {
const msg = typeof detail.error === "string" && detail.error ? detail.error : "send failed";
this.pendingAction = { ...this.pendingAction, phase: "error", error: msg };
const msg =
typeof detail.error === "string" && detail.error
? detail.error
: "send failed";
this.pendingAction = {
...this.pendingAction,
phase: "error",
error: msg,
};
this.#setToast(`Failed: ${msg}`, "error", 4500);
}
this.requestUpdate();
@@ -361,11 +430,17 @@ class OpenClawA2UIHost extends LitElement {
for (const item of ctxItems) {
const key = item?.key;
const value = item?.value ?? null;
if (!key || !value) {continue;}
if (!key || !value) {
continue;
}
if (typeof value.path === "string") {
const resolved = sourceNode
? this.#processor.getData(sourceNode, value.path, surfaceId ?? undefined)
? this.#processor.getData(
sourceNode,
value.path,
surfaceId ?? undefined,
)
: null;
context[key] = resolved;
continue;
@@ -385,7 +460,12 @@ class OpenClawA2UIHost extends LitElement {
}
const actionId = this.#makeActionId();
this.pendingAction = { id: actionId, name, phase: "sending", startedAt: Date.now() };
this.pendingAction = {
id: actionId,
name,
phase: "sending",
startedAt: Date.now(),
};
this.requestUpdate();
const userAction = {
@@ -412,11 +492,23 @@ class OpenClawA2UIHost extends LitElement {
}
} catch (e) {
const msg = String(e?.message ?? e);
this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg };
this.pendingAction = {
id: actionId,
name,
phase: "error",
startedAt: Date.now(),
error: msg,
};
this.#setToast(`Failed: ${msg}`, "error", 4500);
}
} else {
this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: "missing native bridge" };
this.pendingAction = {
id: actionId,
name,
phase: "error",
startedAt: Date.now(),
error: "missing native bridge",
};
this.#setToast("Failed: missing native bridge", "error", 4500);
}
}
@@ -464,24 +556,29 @@ class OpenClawA2UIHost extends LitElement {
? `Failed: ${this.pendingAction.name}`
: "";
return html`
${this.pendingAction && this.pendingAction.phase !== "error"
? html`<div class="status"><div class="spinner"></div><div>${statusText}</div></div>`
return html` ${this.pendingAction && this.pendingAction.phase !== "error"
? html`<div class="status">
<div class="spinner"></div>
<div>${statusText}</div>
</div>`
: ""}
${this.toast
? html`<div class="toast ${this.toast.kind === "error" ? "error" : ""}">${this.toast.text}</div>`
? html`<div class="toast ${this.toast.kind === "error" ? "error" : ""}">
${this.toast.text}
</div>`
: ""}
<section id="surfaces">
${repeat(
this.surfaces,
([surfaceId]) => surfaceId,
([surfaceId, surface]) => html`<a2ui-surface
.surfaceId=${surfaceId}
.surface=${surface}
.processor=${this.#processor}
></a2ui-surface>`
)}
</section>`;
${repeat(
this.surfaces,
([surfaceId]) => surfaceId,
([surfaceId, surface]) =>
html`<a2ui-surface
.surfaceId=${surfaceId}
.surface=${surface}
.processor=${this.#processor}
></a2ui-surface>`,
)}
</section>`;
}
}

View File

@@ -14,7 +14,10 @@ const outputFile = path.resolve(
"a2ui.bundle.js",
);
const a2uiLitDist = path.resolve(repoRoot, "vendor/a2ui/renderers/lit/dist/src");
const a2uiLitDist = path.resolve(
repoRoot,
"vendor/a2ui/renderers/lit/dist/src",
);
const a2uiThemeContext = path.resolve(a2uiLitDist, "0.8/ui/context/theme.js");
export default defineConfig({
@@ -28,10 +31,19 @@ export default defineConfig({
"@a2ui/lit": path.resolve(a2uiLitDist, "index.js"),
"@a2ui/lit/ui": path.resolve(a2uiLitDist, "0.8/ui/ui.js"),
"@openclaw/a2ui-theme-context": a2uiThemeContext,
"@lit/context": path.resolve(repoRoot, "node_modules/@lit/context/index.js"),
"@lit/context": path.resolve(
repoRoot,
"node_modules/@lit/context/index.js",
),
"@lit/context/": path.resolve(repoRoot, "node_modules/@lit/context/"),
"@lit-labs/signals": path.resolve(repoRoot, "node_modules/@lit-labs/signals/index.js"),
"@lit-labs/signals/": path.resolve(repoRoot, "node_modules/@lit-labs/signals/"),
"@lit-labs/signals": path.resolve(
repoRoot,
"node_modules/@lit-labs/signals/index.js",
),
"@lit-labs/signals/": path.resolve(
repoRoot,
"node_modules/@lit-labs/signals/",
),
lit: path.resolve(repoRoot, "node_modules/lit/index.js"),
"lit/": path.resolve(repoRoot, "node_modules/lit/"),
},

View File

@@ -1,438 +1,475 @@
const DEFAULT_PORT = 18792
const DEFAULT_PORT = 18792;
const BADGE = {
on: { text: 'ON', color: '#FF5A36' },
off: { text: '', color: '#000000' },
connecting: { text: '…', color: '#F59E0B' },
error: { text: '!', color: '#B91C1C' },
}
on: { text: "ON", color: "#FF5A36" },
off: { text: "", color: "#000000" },
connecting: { text: "…", color: "#F59E0B" },
error: { text: "!", color: "#B91C1C" },
};
/** @type {WebSocket|null} */
let relayWs = null
let relayWs = null;
/** @type {Promise<void>|null} */
let relayConnectPromise = null
let relayConnectPromise = null;
let debuggerListenersInstalled = false
let debuggerListenersInstalled = false;
let nextSession = 1
let nextSession = 1;
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number}>} */
const tabs = new Map()
const tabs = new Map();
/** @type {Map<string, number>} */
const tabBySession = new Map()
const tabBySession = new Map();
/** @type {Map<string, number>} */
const childSessionToTab = new Map()
const childSessionToTab = new Map();
/** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
const pending = new Map()
const pending = new Map();
function nowStack() {
try {
return new Error().stack || ''
return new Error().stack || "";
} catch {
return ''
return "";
}
}
async function getRelayPort() {
const stored = await chrome.storage.local.get(['relayPort'])
const raw = stored.relayPort
const n = Number.parseInt(String(raw || ''), 10)
if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT
return n
const stored = await chrome.storage.local.get(["relayPort"]);
const raw = stored.relayPort;
const n = Number.parseInt(String(raw || ""), 10);
if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT;
return n;
}
function setBadge(tabId, kind) {
const cfg = BADGE[kind]
void chrome.action.setBadgeText({ tabId, text: cfg.text })
void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color })
void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {})
const cfg = BADGE[kind];
void chrome.action.setBadgeText({ tabId, text: cfg.text });
void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color });
void chrome.action
.setBadgeTextColor({ tabId, color: "#FFFFFF" })
.catch(() => {});
}
async function ensureRelayConnection() {
if (relayWs && relayWs.readyState === WebSocket.OPEN) return
if (relayConnectPromise) return await relayConnectPromise
if (relayWs && relayWs.readyState === WebSocket.OPEN) return;
if (relayConnectPromise) return await relayConnectPromise;
relayConnectPromise = (async () => {
const port = await getRelayPort()
const httpBase = `http://127.0.0.1:${port}`
const wsUrl = `ws://127.0.0.1:${port}/extension`
const port = await getRelayPort();
const httpBase = `http://127.0.0.1:${port}`;
const wsUrl = `ws://127.0.0.1:${port}/extension`;
// Fast preflight: is the relay server up?
try {
await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
await fetch(`${httpBase}/`, {
method: "HEAD",
signal: AbortSignal.timeout(2000),
});
} catch (err) {
throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
throw new Error(
`Relay server not reachable at ${httpBase} (${String(err)})`,
);
}
const ws = new WebSocket(wsUrl)
relayWs = ws
const ws = new WebSocket(wsUrl);
relayWs = ws;
await new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000)
const t = setTimeout(
() => reject(new Error("WebSocket connect timeout")),
5000,
);
ws.onopen = () => {
clearTimeout(t)
resolve()
}
clearTimeout(t);
resolve();
};
ws.onerror = () => {
clearTimeout(t)
reject(new Error('WebSocket connect failed'))
}
clearTimeout(t);
reject(new Error("WebSocket connect failed"));
};
ws.onclose = (ev) => {
clearTimeout(t)
reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`))
}
})
clearTimeout(t);
reject(
new Error(
`WebSocket closed (${ev.code} ${ev.reason || "no reason"})`,
),
);
};
});
ws.onmessage = (event) => void onRelayMessage(String(event.data || ''))
ws.onclose = () => onRelayClosed('closed')
ws.onerror = () => onRelayClosed('error')
ws.onmessage = (event) => void onRelayMessage(String(event.data || ""));
ws.onclose = () => onRelayClosed("closed");
ws.onerror = () => onRelayClosed("error");
if (!debuggerListenersInstalled) {
debuggerListenersInstalled = true
chrome.debugger.onEvent.addListener(onDebuggerEvent)
chrome.debugger.onDetach.addListener(onDebuggerDetach)
debuggerListenersInstalled = true;
chrome.debugger.onEvent.addListener(onDebuggerEvent);
chrome.debugger.onDetach.addListener(onDebuggerDetach);
}
})()
})();
try {
await relayConnectPromise
await relayConnectPromise;
} finally {
relayConnectPromise = null
relayConnectPromise = null;
}
}
function onRelayClosed(reason) {
relayWs = null
relayWs = null;
for (const [id, p] of pending.entries()) {
pending.delete(id)
p.reject(new Error(`Relay disconnected (${reason})`))
pending.delete(id);
p.reject(new Error(`Relay disconnected (${reason})`));
}
for (const tabId of tabs.keys()) {
void chrome.debugger.detach({ tabId }).catch(() => {})
setBadge(tabId, 'connecting')
void chrome.debugger.detach({ tabId }).catch(() => {});
setBadge(tabId, "connecting");
void chrome.action.setTitle({
tabId,
title: 'OpenClaw Browser Relay: disconnected (click to re-attach)',
})
title: "OpenClaw Browser Relay: disconnected (click to re-attach)",
});
}
tabs.clear()
tabBySession.clear()
childSessionToTab.clear()
tabs.clear();
tabBySession.clear();
childSessionToTab.clear();
}
function sendToRelay(payload) {
const ws = relayWs
const ws = relayWs;
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error('Relay not connected')
throw new Error("Relay not connected");
}
ws.send(JSON.stringify(payload))
ws.send(JSON.stringify(payload));
}
async function maybeOpenHelpOnce() {
try {
const stored = await chrome.storage.local.get(['helpOnErrorShown'])
if (stored.helpOnErrorShown === true) return
await chrome.storage.local.set({ helpOnErrorShown: true })
await chrome.runtime.openOptionsPage()
const stored = await chrome.storage.local.get(["helpOnErrorShown"]);
if (stored.helpOnErrorShown === true) return;
await chrome.storage.local.set({ helpOnErrorShown: true });
await chrome.runtime.openOptionsPage();
} catch {
// ignore
}
}
function requestFromRelay(command) {
const id = command.id
const id = command.id;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject })
pending.set(id, { resolve, reject });
try {
sendToRelay(command)
sendToRelay(command);
} catch (err) {
pending.delete(id)
reject(err instanceof Error ? err : new Error(String(err)))
pending.delete(id);
reject(err instanceof Error ? err : new Error(String(err)));
}
})
});
}
async function onRelayMessage(text) {
/** @type {any} */
let msg
let msg;
try {
msg = JSON.parse(text)
msg = JSON.parse(text);
} catch {
return
return;
}
if (msg && msg.method === 'ping') {
if (msg && msg.method === "ping") {
try {
sendToRelay({ method: 'pong' })
sendToRelay({ method: "pong" });
} catch {
// ignore
}
return
return;
}
if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) {
const p = pending.get(msg.id)
if (!p) return
pending.delete(msg.id)
if (msg.error) p.reject(new Error(String(msg.error)))
else p.resolve(msg.result)
return
if (
msg &&
typeof msg.id === "number" &&
(msg.result !== undefined || msg.error !== undefined)
) {
const p = pending.get(msg.id);
if (!p) return;
pending.delete(msg.id);
if (msg.error) p.reject(new Error(String(msg.error)));
else p.resolve(msg.result);
return;
}
if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') {
if (msg && typeof msg.id === "number" && msg.method === "forwardCDPCommand") {
try {
const result = await handleForwardCdpCommand(msg)
sendToRelay({ id: msg.id, result })
const result = await handleForwardCdpCommand(msg);
sendToRelay({ id: msg.id, result });
} catch (err) {
sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) })
sendToRelay({
id: msg.id,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
function getTabBySessionId(sessionId) {
const direct = tabBySession.get(sessionId)
if (direct) return { tabId: direct, kind: 'main' }
const child = childSessionToTab.get(sessionId)
if (child) return { tabId: child, kind: 'child' }
return null
const direct = tabBySession.get(sessionId);
if (direct) return { tabId: direct, kind: "main" };
const child = childSessionToTab.get(sessionId);
if (child) return { tabId: child, kind: "child" };
return null;
}
function getTabByTargetId(targetId) {
for (const [tabId, tab] of tabs.entries()) {
if (tab.targetId === targetId) return tabId
if (tab.targetId === targetId) return tabId;
}
return null
return null;
}
async function attachTab(tabId, opts = {}) {
const debuggee = { tabId }
await chrome.debugger.attach(debuggee, '1.3')
await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {})
const debuggee = { tabId };
await chrome.debugger.attach(debuggee, "1.3");
await chrome.debugger.sendCommand(debuggee, "Page.enable").catch(() => {});
const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'))
const targetInfo = info?.targetInfo
const targetId = String(targetInfo?.targetId || '').trim()
const info = /** @type {any} */ (
await chrome.debugger.sendCommand(debuggee, "Target.getTargetInfo")
);
const targetInfo = info?.targetInfo;
const targetId = String(targetInfo?.targetId || "").trim();
if (!targetId) {
throw new Error('Target.getTargetInfo returned no targetId')
throw new Error("Target.getTargetInfo returned no targetId");
}
const sessionId = `cb-tab-${nextSession++}`
const attachOrder = nextSession
const sessionId = `cb-tab-${nextSession++}`;
const attachOrder = nextSession;
tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder })
tabBySession.set(sessionId, tabId)
tabs.set(tabId, { state: "connected", sessionId, targetId, attachOrder });
tabBySession.set(sessionId, tabId);
void chrome.action.setTitle({
tabId,
title: 'OpenClaw Browser Relay: attached (click to detach)',
})
title: "OpenClaw Browser Relay: attached (click to detach)",
});
if (!opts.skipAttachedEvent) {
sendToRelay({
method: 'forwardCDPEvent',
method: "forwardCDPEvent",
params: {
method: 'Target.attachedToTarget',
method: "Target.attachedToTarget",
params: {
sessionId,
targetInfo: { ...targetInfo, attached: true },
waitingForDebugger: false,
},
},
})
});
}
setBadge(tabId, 'on')
return { sessionId, targetId }
setBadge(tabId, "on");
return { sessionId, targetId };
}
async function detachTab(tabId, reason) {
const tab = tabs.get(tabId)
const tab = tabs.get(tabId);
if (tab?.sessionId && tab?.targetId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
method: "forwardCDPEvent",
params: {
method: 'Target.detachedFromTarget',
method: "Target.detachedFromTarget",
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason },
},
})
});
} catch {
// ignore
}
}
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
tabs.delete(tabId)
if (tab?.sessionId) tabBySession.delete(tab.sessionId);
tabs.delete(tabId);
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
if (parentTabId === tabId) childSessionToTab.delete(childSessionId);
}
try {
await chrome.debugger.detach({ tabId })
await chrome.debugger.detach({ tabId });
} catch {
// ignore
}
setBadge(tabId, 'off')
setBadge(tabId, "off");
void chrome.action.setTitle({
tabId,
title: 'OpenClaw Browser Relay (click to attach/detach)',
})
title: "OpenClaw Browser Relay (click to attach/detach)",
});
}
async function connectOrToggleForActiveTab() {
const [active] = await chrome.tabs.query({ active: true, currentWindow: true })
const tabId = active?.id
if (!tabId) return
const [active] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
const tabId = active?.id;
if (!tabId) return;
const existing = tabs.get(tabId)
if (existing?.state === 'connected') {
await detachTab(tabId, 'toggle')
return
const existing = tabs.get(tabId);
if (existing?.state === "connected") {
await detachTab(tabId, "toggle");
return;
}
tabs.set(tabId, { state: 'connecting' })
setBadge(tabId, 'connecting')
tabs.set(tabId, { state: "connecting" });
setBadge(tabId, "connecting");
void chrome.action.setTitle({
tabId,
title: 'OpenClaw Browser Relay: connecting to local relay…',
})
title: "OpenClaw Browser Relay: connecting to local relay…",
});
try {
await ensureRelayConnection()
await attachTab(tabId)
await ensureRelayConnection();
await attachTab(tabId);
} catch (err) {
tabs.delete(tabId)
setBadge(tabId, 'error')
tabs.delete(tabId);
setBadge(tabId, "error");
void chrome.action.setTitle({
tabId,
title: 'OpenClaw Browser Relay: relay not running (open options for setup)',
})
void maybeOpenHelpOnce()
title:
"OpenClaw Browser Relay: relay not running (open options for setup)",
});
void maybeOpenHelpOnce();
// Extra breadcrumbs in chrome://extensions service worker logs.
const message = err instanceof Error ? err.message : String(err)
console.warn('attach failed', message, nowStack())
const message = err instanceof Error ? err.message : String(err);
console.warn("attach failed", message, nowStack());
}
}
async function handleForwardCdpCommand(msg) {
const method = String(msg?.params?.method || '').trim()
const params = msg?.params?.params || undefined
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
const method = String(msg?.params?.method || "").trim();
const params = msg?.params?.params || undefined;
const sessionId =
typeof msg?.params?.sessionId === "string"
? msg.params.sessionId
: undefined;
// Map command to tab
const bySession = sessionId ? getTabBySessionId(sessionId) : null
const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined
const bySession = sessionId ? getTabBySessionId(sessionId) : null;
const targetId =
typeof params?.targetId === "string" ? params.targetId : undefined;
const tabId =
bySession?.tabId ||
(targetId ? getTabByTargetId(targetId) : null) ||
(() => {
// No sessionId: pick the first connected tab (stable-ish).
for (const [id, tab] of tabs.entries()) {
if (tab.state === 'connected') return id
if (tab.state === "connected") return id;
}
return null
})()
return null;
})();
if (!tabId) throw new Error(`No attached tab for method ${method}`)
if (!tabId) throw new Error(`No attached tab for method ${method}`);
/** @type {chrome.debugger.DebuggerSession} */
const debuggee = { tabId }
const debuggee = { tabId };
if (method === 'Runtime.enable') {
if (method === "Runtime.enable") {
try {
await chrome.debugger.sendCommand(debuggee, 'Runtime.disable')
await new Promise((r) => setTimeout(r, 50))
await chrome.debugger.sendCommand(debuggee, "Runtime.disable");
await new Promise((r) => setTimeout(r, 50));
} catch {
// ignore
}
return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params)
return await chrome.debugger.sendCommand(
debuggee,
"Runtime.enable",
params,
);
}
if (method === 'Target.createTarget') {
const url = typeof params?.url === 'string' ? params.url : 'about:blank'
const tab = await chrome.tabs.create({ url, active: false })
if (!tab.id) throw new Error('Failed to create tab')
await new Promise((r) => setTimeout(r, 100))
const attached = await attachTab(tab.id)
return { targetId: attached.targetId }
if (method === "Target.createTarget") {
const url = typeof params?.url === "string" ? params.url : "about:blank";
const tab = await chrome.tabs.create({ url, active: false });
if (!tab.id) throw new Error("Failed to create tab");
await new Promise((r) => setTimeout(r, 100));
const attached = await attachTab(tab.id);
return { targetId: attached.targetId };
}
if (method === 'Target.closeTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toClose = target ? getTabByTargetId(target) : tabId
if (!toClose) return { success: false }
if (method === "Target.closeTarget") {
const target = typeof params?.targetId === "string" ? params.targetId : "";
const toClose = target ? getTabByTargetId(target) : tabId;
if (!toClose) return { success: false };
try {
await chrome.tabs.remove(toClose)
await chrome.tabs.remove(toClose);
} catch {
return { success: false }
return { success: false };
}
return { success: true }
return { success: true };
}
if (method === 'Target.activateTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toActivate = target ? getTabByTargetId(target) : tabId
if (!toActivate) return {}
const tab = await chrome.tabs.get(toActivate).catch(() => null)
if (!tab) return {}
if (method === "Target.activateTarget") {
const target = typeof params?.targetId === "string" ? params.targetId : "";
const toActivate = target ? getTabByTargetId(target) : tabId;
if (!toActivate) return {};
const tab = await chrome.tabs.get(toActivate).catch(() => null);
if (!tab) return {};
if (tab.windowId) {
await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
await chrome.windows
.update(tab.windowId, { focused: true })
.catch(() => {});
}
await chrome.tabs.update(toActivate, { active: true }).catch(() => {})
return {}
await chrome.tabs.update(toActivate, { active: true }).catch(() => {});
return {};
}
const tabState = tabs.get(tabId)
const mainSessionId = tabState?.sessionId
const tabState = tabs.get(tabId);
const mainSessionId = tabState?.sessionId;
const debuggerSession =
sessionId && mainSessionId && sessionId !== mainSessionId
? { ...debuggee, sessionId }
: debuggee
: debuggee;
return await chrome.debugger.sendCommand(debuggerSession, method, params)
return await chrome.debugger.sendCommand(debuggerSession, method, params);
}
function onDebuggerEvent(source, method, params) {
const tabId = source.tabId
if (!tabId) return
const tab = tabs.get(tabId)
if (!tab?.sessionId) return
const tabId = source.tabId;
if (!tabId) return;
const tab = tabs.get(tabId);
if (!tab?.sessionId) return;
if (method === 'Target.attachedToTarget' && params?.sessionId) {
childSessionToTab.set(String(params.sessionId), tabId)
if (method === "Target.attachedToTarget" && params?.sessionId) {
childSessionToTab.set(String(params.sessionId), tabId);
}
if (method === 'Target.detachedFromTarget' && params?.sessionId) {
childSessionToTab.delete(String(params.sessionId))
if (method === "Target.detachedFromTarget" && params?.sessionId) {
childSessionToTab.delete(String(params.sessionId));
}
try {
sendToRelay({
method: 'forwardCDPEvent',
method: "forwardCDPEvent",
params: {
sessionId: source.sessionId || tab.sessionId,
method,
params,
},
})
});
} catch {
// ignore
}
}
function onDebuggerDetach(source, reason) {
const tabId = source.tabId
if (!tabId) return
if (!tabs.has(tabId)) return
void detachTab(tabId, reason)
const tabId = source.tabId;
if (!tabId) return;
if (!tabs.has(tabId)) return;
void detachTab(tabId, reason);
}
chrome.action.onClicked.addListener(() => void connectOrToggleForActiveTab())
chrome.action.onClicked.addListener(() => void connectOrToggleForActiveTab());
chrome.runtime.onInstalled.addListener(() => {
// Useful: first-time instructions.
void chrome.runtime.openOptionsPage()
})
void chrome.runtime.openOptionsPage();
});

View File

@@ -12,16 +12,31 @@
--border: color-mix(in oklab, canvasText 18%, transparent);
--muted: color-mix(in oklab, canvasText 70%, transparent);
--shadow: 0 10px 30px color-mix(in oklab, canvasText 18%, transparent);
font-family: ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Rounded",
"SF Pro Display", "Segoe UI", sans-serif;
font-family:
ui-rounded,
system-ui,
-apple-system,
BlinkMacSystemFont,
"SF Pro Rounded",
"SF Pro Display",
"Segoe UI",
sans-serif;
line-height: 1.4;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1000px 500px at 10% 0%, color-mix(in oklab, var(--accent) 30%, transparent), transparent 70%),
radial-gradient(900px 450px at 90% 0%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 75%),
radial-gradient(
1000px 500px at 10% 0%,
color-mix(in oklab, var(--accent) 30%, transparent),
transparent 70%
),
radial-gradient(
900px 450px at 90% 0%,
color-mix(in oklab, var(--accent) 18%, transparent),
transparent 75%
),
canvas;
color: canvasText;
}
@@ -106,7 +121,8 @@
}
input:focus {
border-color: color-mix(in oklab, var(--accent) 70%, transparent);
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 20%, transparent);
box-shadow: 0 0 0 4px
color-mix(in oklab, var(--accent) 20%, transparent);
}
button {
padding: 10px 14px;
@@ -131,7 +147,8 @@
color: var(--muted);
}
code {
font-family: ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
font-family:
ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
font-size: 12px;
}
a {
@@ -143,10 +160,10 @@
color: color-mix(in oklab, var(--accent) 70%, canvasText 30%);
min-height: 16px;
}
.status[data-kind='ok'] {
.status[data-kind="ok"] {
color: color-mix(in oklab, #16a34a 75%, canvasText 25%);
}
.status[data-kind='error'] {
.status[data-kind="error"] {
color: color-mix(in oklab, #ef4444 75%, canvasText 25%);
}
</style>
@@ -159,7 +176,9 @@
</div>
<div>
<h1>OpenClaw Browser Relay</h1>
<p class="subtitle">Click the toolbar button on a tab to attach / detach.</p>
<p class="subtitle">
Click the toolbar button on a tab to attach / detach.
</p>
</div>
</header>
@@ -167,11 +186,19 @@
<div class="card">
<h2>Getting started</h2>
<p>
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
Start OpenClaws browser relay on this machine (Gateway or node host), then click the toolbar button again.
If you see a red <code>!</code> badge on the extension icon, the
relay server is not reachable. Start OpenClaws browser relay on
this machine (Gateway or node host), then click the toolbar button
again.
</p>
<p>
Full guide (install, remote Gateway, security): <a href="https://docs.openclaw.ai/tools/chrome-extension" target="_blank" rel="noreferrer">docs.openclaw.ai/tools/chrome-extension</a>
Full guide (install, remote Gateway, security):
<a
href="https://docs.openclaw.ai/tools/chrome-extension"
target="_blank"
rel="noreferrer"
>docs.openclaw.ai/tools/chrome-extension</a
>
</p>
</div>
@@ -183,8 +210,10 @@
<button id="save" type="button">Save</button>
</div>
<div class="hint">
Default: <code>18792</code>. Extension connects to: <code id="relay-url">http://127.0.0.1:&lt;port&gt;/</code>.
Only change this if your OpenClaw profile uses a different <code>cdpUrl</code> port.
Default: <code>18792</code>. Extension connects to:
<code id="relay-url">http://127.0.0.1:&lt;port&gt;/</code>. Only
change this if your OpenClaw profile uses a different
<code>cdpUrl</code> port.
</div>
<div class="status" id="status"></div>
</div>

View File

@@ -1,59 +1,59 @@
const DEFAULT_PORT = 18792
const DEFAULT_PORT = 18792;
function clampPort(value) {
const n = Number.parseInt(String(value || ''), 10)
if (!Number.isFinite(n)) return DEFAULT_PORT
if (n <= 0 || n > 65535) return DEFAULT_PORT
return n
const n = Number.parseInt(String(value || ""), 10);
if (!Number.isFinite(n)) return DEFAULT_PORT;
if (n <= 0 || n > 65535) return DEFAULT_PORT;
return n;
}
function updateRelayUrl(port) {
const el = document.getElementById('relay-url')
if (!el) return
el.textContent = `http://127.0.0.1:${port}/`
const el = document.getElementById("relay-url");
if (!el) return;
el.textContent = `http://127.0.0.1:${port}/`;
}
function setStatus(kind, message) {
const status = document.getElementById('status')
if (!status) return
status.dataset.kind = kind || ''
status.textContent = message || ''
const status = document.getElementById("status");
if (!status) return;
status.dataset.kind = kind || "";
status.textContent = message || "";
}
async function checkRelayReachable(port) {
const url = `http://127.0.0.1:${port}/`
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), 900)
const url = `http://127.0.0.1:${port}/`;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 900);
try {
const res = await fetch(url, { method: 'HEAD', signal: ctrl.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setStatus('ok', `Relay reachable at ${url}`)
const res = await fetch(url, { method: "HEAD", signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setStatus("ok", `Relay reachable at ${url}`);
} catch {
setStatus(
'error',
"error",
`Relay not reachable at ${url}. Start OpenClaws browser relay on this machine, then click the toolbar button again.`,
)
);
} finally {
clearTimeout(t)
clearTimeout(t);
}
}
async function load() {
const stored = await chrome.storage.local.get(['relayPort'])
const port = clampPort(stored.relayPort)
document.getElementById('port').value = String(port)
updateRelayUrl(port)
await checkRelayReachable(port)
const stored = await chrome.storage.local.get(["relayPort"]);
const port = clampPort(stored.relayPort);
document.getElementById("port").value = String(port);
updateRelayUrl(port);
await checkRelayReachable(port);
}
async function save() {
const input = document.getElementById('port')
const port = clampPort(input.value)
await chrome.storage.local.set({ relayPort: port })
input.value = String(port)
updateRelayUrl(port)
await checkRelayReachable(port)
const input = document.getElementById("port");
const port = clampPort(input.value);
await chrome.storage.local.set({ relayPort: port });
input.value = String(port);
updateRelayUrl(port);
await checkRelayReachable(port);
}
document.getElementById('save').addEventListener('click', () => void save())
void load()
document.getElementById("save").addEventListener("click", () => void save());
void load();

View File

@@ -62,7 +62,7 @@ function V(e, n, t, r) {
: { type: "code_content", raw: e, content: e, indent: 0, line: i };
let o = e.match(d.codeFence);
if (o) {
(n.inCodeBlock = !0), (n.codeBlockFence = o[2]);
((n.inCodeBlock = !0), (n.codeBlockFence = o[2]));
let f = o[3] || "";
return {
type: "code_fence_start",
@@ -208,13 +208,13 @@ function b(e) {
};
for (; t < e.length; ) {
if (e[t] === "\\" && t + 1 < e.length) {
(r += e[t + 1]), (t += 2);
((r += e[t + 1]), (t += 2));
continue;
}
if (e[t] === "`") {
let o = T(e, t);
if (o) {
i(), n.push(o.token), (t = o.end);
(i(), n.push(o.token), (t = o.end));
continue;
}
}
@@ -222,39 +222,39 @@ function b(e) {
let o = e[t] === "!",
s = J(e, o ? t + 1 : t, o);
if (s) {
i(), n.push(s.token), (t = s.end);
(i(), n.push(s.token), (t = s.end));
continue;
}
}
if (e[t] === "*" || e[t] === "_") {
let o = L(e, t);
if (o) {
i(), n.push(o.token), (t = o.end);
(i(), n.push(o.token), (t = o.end));
continue;
}
}
if (e[t] === "~" && e[t + 1] === "~") {
let o = E(e, t);
if (o) {
i(), n.push(o.token), (t = o.end);
(i(), n.push(o.token), (t = o.end));
continue;
}
}
if (e[t] === "=" && e[t + 1] === "=") {
let o = A(e, t);
if (o) {
i(), n.push(o.token), (t = o.end);
(i(), n.push(o.token), (t = o.end));
continue;
}
}
(r += e[t]), t++;
((r += e[t]), t++);
}
return i(), n;
return (i(), n);
}
function T(e, n) {
let t = 0,
r = n;
for (; r < e.length && e[r] === "`"; ) t++, r++;
for (; r < e.length && e[r] === "`"; ) (t++, r++);
if (t === 0) return null;
let i = "`".repeat(t),
o = e.indexOf(i, r);
@@ -267,7 +267,7 @@ function J(e, n, t = !1) {
let r = 1,
i = n + 1;
for (; i < e.length && r > 0; )
e[i] === "[" ? r++ : e[i] === "]" ? r-- : e[i] === "\\" && i++, i++;
(e[i] === "[" ? r++ : e[i] === "]" ? r-- : e[i] === "\\" && i++, i++);
if (r !== 0) return null;
let o = e.slice(n + 1, i - 1);
if (e[i] !== "(") return null;
@@ -275,7 +275,7 @@ function J(e, n, t = !1) {
c = s,
l = 1;
for (; c < e.length && l > 0; )
e[c] === "(" ? l++ : e[c] === ")" ? l-- : e[c] === "\\" && c++, c++;
(e[c] === "(" ? l++ : e[c] === ")" ? l-- : e[c] === "\\" && c++, c++);
if (l !== 0) return null;
let a = e.slice(s, c - 1).trim(),
u = a,
@@ -295,7 +295,7 @@ function L(e, n) {
if (t !== "*" && t !== "_") return null;
let r = 0,
i = n;
for (; i < e.length && e[i] === t && r < 3; ) r++, i++;
for (; i < e.length && e[i] === t && r < 3; ) (r++, i++);
if (r === 0) return null;
let o = t.repeat(r),
s = i;
@@ -326,8 +326,8 @@ function L(e, n) {
end: c + r,
}
: r === 2
? { token: { type: "bold", content: l, children: a }, end: c + r }
: { token: { type: "italic", content: l, children: a }, end: c + r };
? { token: { type: "bold", content: l, children: a }, end: c + r }
: { token: { type: "italic", content: l, children: a }, end: c + r };
}
return null;
}
@@ -469,7 +469,7 @@ function te(e) {
case "image":
return fe(e);
default:
return e.index++, null;
return (e.index++, null);
}
}
function re(e) {
@@ -490,7 +490,7 @@ function re(e) {
function ie(e) {
let n = [];
for (; e.index < e.tokens.length && e.tokens[e.index].type === "paragraph"; )
n.push(e.tokens[e.index].content), e.index++;
(n.push(e.tokens[e.index].content), e.index++);
let t = $(n.join(" "));
return {
id: e.options.generateId(),
@@ -508,7 +508,6 @@ function oe(e) {
e.index < e.tokens.length &&
e.tokens[e.index].type === "bullet_list_item" &&
e.tokens[e.index].indent >= t;
) {
let r = e.tokens[e.index];
if (r.indent > t) {
@@ -516,14 +515,14 @@ function oe(e) {
continue;
}
let i = $(r.content);
n.push({
(n.push({
id: e.options.generateId(),
type: "paragraph",
content: i,
children: [],
props: {},
}),
e.index++;
e.index++);
}
return {
id: e.options.generateId(),
@@ -541,7 +540,6 @@ function ce(e) {
e.index < e.tokens.length &&
e.tokens[e.index].type === "numbered_list_item" &&
e.tokens[e.index].indent >= t;
) {
let r = e.tokens[e.index];
if (r.indent > t) {
@@ -549,14 +547,14 @@ function ce(e) {
continue;
}
let i = $(r.content);
n.push({
(n.push({
id: e.options.generateId(),
type: "paragraph",
content: i,
children: [],
props: {},
}),
e.index++;
e.index++);
}
return {
id: e.options.generateId(),
@@ -587,9 +585,8 @@ function le(e) {
for (
e.index++;
e.index < e.tokens.length && e.tokens[e.index].type === "code_content";
)
r.push(e.tokens[e.index].content), e.index++;
(r.push(e.tokens[e.index].content), e.index++);
return (
e.index < e.tokens.length &&
e.tokens[e.index].type === "code_fence_end" &&
@@ -599,7 +596,7 @@ function le(e) {
type: "codeBlock",
content: F(
r.join(`
`)
`),
),
children: [],
props: { language: t || void 0 },
@@ -609,10 +606,10 @@ function le(e) {
function ae(e) {
let n = [];
for (; e.index < e.tokens.length && e.tokens[e.index].type === "blockquote"; )
n.push(e.tokens[e.index].content), e.index++;
(n.push(e.tokens[e.index].content), e.index++);
let t = $(
n.join(`
`)
`),
);
return {
id: e.options.generateId(),
@@ -627,10 +624,10 @@ function ue(e) {
e.index++;
let r = [];
for (; e.index < e.tokens.length && e.tokens[e.index].type === "blockquote"; )
r.push(e.tokens[e.index].content), e.index++;
(r.push(e.tokens[e.index].content), e.index++);
let i = $(
r.join(`
`)
`),
);
return {
id: e.options.generateId(),
@@ -663,18 +660,17 @@ function he(e) {
e.index < e.tokens.length &&
(e.tokens[e.index].type === "table_row" ||
e.tokens[e.index].type === "table_separator");
) {
let s = e.tokens[e.index];
if (s.type === "table_separator") {
(r = pe(s.content)), (o = !0), e.index++;
((r = pe(s.content)), (o = !0), e.index++);
continue;
}
let c = s.content
.split("|")
.map((l) => l.trim())
.filter((l) => l !== "");
i && !o ? ((t = c), (i = !1)) : o && n.push(c), e.index++;
(i && !o ? ((t = c), (i = !1)) : o && n.push(c), e.index++);
}
return (
!o && t.length > 0 && (n.unshift(t), (t = [])),
@@ -775,7 +771,7 @@ function S(e, n) {
case "blockquote":
return `<blockquote class="${t}blockquote">${m(
e.content,
n
n,
)}</blockquote>`;
case "table":
return xe(e, n);
@@ -846,7 +842,7 @@ function xe(e, n) {
.join("")}</tr></thead>`
: "",
l = i.map(
(a) => `<tr>${a.map((u, h) => `<td${s(h)}>${g(u)}</td>`).join("")}</tr>`
(a) => `<tr>${a.map((u, h) => `<td${s(h)}>${g(u)}</td>`).join("")}</tr>`,
).join(`
`);
return `<table class="${t}table">
@@ -933,11 +929,11 @@ function Te(e) {
.createHighlighter;
if (!r) {
console.warn(
"@create-markdown/preview: Shiki module loaded but createHighlighter not found"
"@create-markdown/preview: Shiki module loaded but createHighlighter not found",
);
return;
}
(M = r({
((M = r({
themes: [n.theme, n.darkTheme].filter(Boolean),
langs: [
"javascript",
@@ -956,10 +952,10 @@ function Te(e) {
...n.langs,
],
})),
(v = await M);
(v = await M));
} catch {
console.warn(
"@create-markdown/preview: Shiki not available. Install with: npm install shiki"
"@create-markdown/preview: Shiki not available. Install with: npm install shiki",
);
}
},
@@ -1036,17 +1032,17 @@ function Ee(e) {
if (!j)
try {
let t = await import("/mermaid@>=10.0.0?target=es2022");
(B = t.default || t),
((B = t.default || t),
B.initialize({
startOnLoad: !1,
theme: n.theme,
securityLevel: "loose",
...n.config,
}),
(j = !0);
(j = !0));
} catch {
console.warn(
"@create-markdown/preview: Mermaid not available. Install with: npm install mermaid"
"@create-markdown/preview: Mermaid not available. Install with: npm install mermaid",
);
}
},
@@ -1069,7 +1065,7 @@ function Ee(e) {
i = n.classPrefix,
o = new RegExp(
`<pre class="${i}mermaid" id="([^"]+)">([\\s\\S]*?)</pre>`,
"g"
"g",
),
s = [...t.matchAll(o)];
for (let c of s) {
@@ -1150,7 +1146,7 @@ var _ = class extends HTMLElement {
return ["theme", "link-target", "async"];
}
constructor() {
super(),
(super(),
(this.shadow = this.attachShadow({ mode: "open" })),
(this.plugins = []),
(this.defaultTheme = "github"),
@@ -1159,7 +1155,7 @@ var _ = class extends HTMLElement {
(this.contentElement = document.createElement("div")),
(this.contentElement.className = "markdown-preview-content"),
this.shadow.appendChild(this.contentElement),
this.updateStyles();
this.updateStyles());
}
connectedCallback() {
this.render();
@@ -1168,10 +1164,10 @@ var _ = class extends HTMLElement {
this.render();
}
setPlugins(n) {
(this.plugins = n), this.render();
((this.plugins = n), this.render());
}
setDefaultTheme(n) {
(this.defaultTheme = n), this.render();
((this.defaultTheme = n), this.render());
}
getMarkdown() {
let n = this.getAttribute("blocks");
@@ -1187,10 +1183,10 @@ var _ = class extends HTMLElement {
return this.textContent || "";
}
setMarkdown(n) {
(this.textContent = n), this.render();
((this.textContent = n), this.render());
}
setBlocks(n) {
this.setAttribute("blocks", JSON.stringify(n)), this.render();
(this.setAttribute("blocks", JSON.stringify(n)), this.render());
}
getOptions() {
let n = this.getAttribute("theme") || this.defaultTheme,
@@ -1204,7 +1200,8 @@ var _ = class extends HTMLElement {
return JSON.parse(n);
} catch {
return (
console.warn("Invalid blocks JSON in markdown-preview element"), []
console.warn("Invalid blocks JSON in markdown-preview element"),
[]
);
}
let t = this.textContent || "";
@@ -1216,12 +1213,12 @@ var _ = class extends HTMLElement {
r = this.hasAttribute("async") || this.plugins.length > 0;
try {
let i;
r ? (i = await me(n, t)) : (i = C(n, t)),
(this.contentElement.innerHTML = i);
(r ? (i = await me(n, t)) : (i = C(n, t)),
(this.contentElement.innerHTML = i));
} catch (i) {
console.error("Error rendering markdown preview:", i),
(console.error("Error rendering markdown preview:", i),
(this.contentElement.innerHTML =
'<div class="error">Error rendering content</div>');
'<div class="error">Error rendering content</div>'));
}
}
updateStyles() {
@@ -1259,7 +1256,7 @@ function Se(e) {
if (((O = t), (q = r), !customElements.get(n))) {
class i extends _ {
constructor() {
super(), this.setPlugins(O), this.setDefaultTheme(q);
(super(), this.setPlugins(O), this.setDefaultTheme(q));
}
}
customElements.define(n, i);

View File

@@ -522,7 +522,10 @@ html[data-theme="dark"] .docs-chat-user {
if (!isDragging) return;
// Panel is on right, so dragging left increases width
const delta = startX - e.clientX;
const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta));
const newWidth = Math.min(
MAX_WIDTH,
Math.max(MIN_WIDTH, startWidth + delta),
);
customWidth = newWidth;
panel.style.width = newWidth + "px";
});
@@ -601,7 +604,8 @@ html[data-theme="dark"] .docs-chat-user {
if (!response.ok) {
try {
const errorData = await response.json();
fullText = errorData.error || "Something went wrong. Please try again.";
fullText =
errorData.error || "Something went wrong. Please try again.";
} catch {
fullText = "Something went wrong. Please try again.";
}

View File

@@ -106,7 +106,10 @@
visibility: hidden;
pointer-events: none;
z-index: 20;
transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s ease;
transition:
opacity 0.18s ease,
transform 0.18s ease,
visibility 0.18s ease;
}
.showcase-preview img {
@@ -146,7 +149,8 @@
border-radius: var(--radius-sm);
border: 1px solid color-mix(in oklab, var(--frame-border) 18%, transparent);
overflow-x: auto;
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--code-accent) 12%, transparent);
box-shadow: inset 0 0 0 1px
color-mix(in oklab, var(--code-accent) 12%, transparent);
}
.markdown pre code {
@@ -168,7 +172,8 @@
.markdown th,
.markdown td {
padding: 10px 10px;
border-bottom: 1px solid color-mix(in oklab, var(--frame-border) 15%, transparent);
border-bottom: 1px solid
color-mix(in oklab, var(--frame-border) 15%, transparent);
vertical-align: top;
}

View File

@@ -1,10 +1,13 @@
:root {
--font-body: "Fragment Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-body:
"Fragment Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
--font-pixel: "Pixelify Sans", system-ui, sans-serif;
--radius: 14px;
--radius-sm: 10px;
--border: 2px;
--shadow-px: 0 0 0 var(--border) var(--frame-border), 0 12px 0 -4px rgba(0, 0, 0, 0.25);
--shadow-px:
0 0 0 var(--border) var(--frame-border), 0 12px 0 -4px rgba(0, 0, 0, 0.25);
--scanline-size: 6px;
--scanline-opacity: 0.08;
}
@@ -83,9 +86,21 @@ body {
font-family: var(--font-body);
color: var(--text);
background:
radial-gradient(1100px 700px at 20% -10%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 55%),
radial-gradient(900px 600px at 95% 10%, color-mix(in oklab, var(--accent2) 14%, transparent), transparent 60%),
radial-gradient(900px 600px at 50% 120%, color-mix(in oklab, var(--link) 10%, transparent), transparent 55%),
radial-gradient(
1100px 700px at 20% -10%,
color-mix(in oklab, var(--accent) 18%, transparent),
transparent 55%
),
radial-gradient(
900px 600px at 95% 10%,
color-mix(in oklab, var(--accent2) 14%, transparent),
transparent 60%
),
radial-gradient(
900px 600px at 50% 120%,
color-mix(in oklab, var(--link) 10%, transparent),
transparent 55%
),
linear-gradient(180deg, var(--bg0), var(--bg1));
overflow-x: hidden;
}
@@ -125,8 +140,11 @@ body::after {
max-width: 1120px;
margin: 0 auto;
border-radius: var(--radius);
background:
linear-gradient(180deg, color-mix(in oklab, var(--panel2) 88%, transparent), color-mix(in oklab, var(--panel) 92%, transparent));
background: linear-gradient(
180deg,
color-mix(in oklab, var(--panel2) 88%, transparent),
color-mix(in oklab, var(--panel) 92%, transparent)
);
box-shadow: var(--shadow-px);
border: var(--border) solid var(--frame-border);
overflow: hidden;
@@ -139,8 +157,16 @@ body::after {
gap: 12px;
padding: 14px 14px 12px;
background:
linear-gradient(90deg, color-mix(in oklab, var(--accent) 26%, transparent), transparent 42%),
linear-gradient(180deg, color-mix(in oklab, var(--panel) 90%, #000 10%), color-mix(in oklab, var(--panel2) 90%, #000 10%));
linear-gradient(
90deg,
color-mix(in oklab, var(--accent) 26%, transparent),
transparent 42%
),
linear-gradient(
180deg,
color-mix(in oklab, var(--panel) 90%, #000 10%),
color-mix(in oklab, var(--panel2) 90%, #000 10%)
);
}
.brand {
@@ -155,7 +181,9 @@ body::after {
width: 40px;
height: 40px;
image-rendering: pixelated;
filter: drop-shadow(0 2px 0 color-mix(in oklab, var(--frame-border) 80%, transparent));
filter: drop-shadow(
0 2px 0 color-mix(in oklab, var(--frame-border) 80%, transparent)
);
}
.brand__text {
@@ -193,9 +221,14 @@ body::after {
padding: 8px 12px 8px 10px;
border-radius: 12px;
background:
linear-gradient(140deg, color-mix(in oklab, var(--accent) 10%, transparent), transparent 60%),
linear-gradient(
140deg,
color-mix(in oklab, var(--accent) 10%, transparent),
transparent 60%
),
color-mix(in oklab, var(--panel) 92%, transparent);
border: var(--border) solid color-mix(in oklab, var(--frame-border) 80%, transparent);
border: var(--border) solid
color-mix(in oklab, var(--frame-border) 80%, transparent);
color: var(--text);
text-decoration: none;
box-shadow:
@@ -222,7 +255,11 @@ body::after {
.titlebar__cta--accent {
background:
linear-gradient(120deg, color-mix(in oklab, var(--accent) 22%, transparent), transparent 70%),
linear-gradient(
120deg,
color-mix(in oklab, var(--accent) 22%, transparent),
transparent 70%
),
color-mix(in oklab, var(--panel) 88%, transparent);
border-color: color-mix(in oklab, var(--accent) 60%, var(--frame-border));
}
@@ -247,7 +284,8 @@ body::after {
background: var(--code-bg);
color: var(--code-accent);
border: 1px solid color-mix(in oklab, var(--code-accent) 30%, transparent);
box-shadow: inset 0 0 12px color-mix(in oklab, var(--code-accent) 25%, transparent);
box-shadow: inset 0 0 12px
color-mix(in oklab, var(--code-accent) 25%, transparent);
}
.theme-toggle {
@@ -303,8 +341,13 @@ body::after {
justify-content: space-between;
gap: 10px;
padding: 10px 14px 12px;
border-top: 1px solid color-mix(in oklab, var(--frame-border) 25%, transparent);
background: linear-gradient(180deg, transparent, color-mix(in oklab, var(--panel2) 78%, transparent));
border-top: 1px solid
color-mix(in oklab, var(--frame-border) 25%, transparent);
background: linear-gradient(
180deg,
transparent,
color-mix(in oklab, var(--panel2) 78%, transparent)
);
}
.nav {
@@ -348,8 +391,14 @@ body::after {
width: 10px;
height: 10px;
border-radius: 999px;
background: radial-gradient(circle at 30% 30%, var(--accent2), color-mix(in oklab, var(--accent2) 30%, #000));
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent2) 18%, transparent), 0 0 18px color-mix(in oklab, var(--accent2) 50%, transparent);
background: radial-gradient(
circle at 30% 30%,
var(--accent2),
color-mix(in oklab, var(--accent2) 30%, #000)
);
box-shadow:
0 0 0 2px color-mix(in oklab, var(--accent2) 18%, transparent),
0 0 18px color-mix(in oklab, var(--accent2) 50%, transparent);
}
.content {
@@ -362,7 +411,11 @@ body::after {
margin: 0 auto;
border-radius: var(--radius);
border: var(--border) solid var(--frame-border);
background: linear-gradient(180deg, color-mix(in oklab, var(--panel) 92%, transparent), color-mix(in oklab, var(--panel2) 86%, transparent));
background: linear-gradient(
180deg,
color-mix(in oklab, var(--panel) 92%, transparent),
color-mix(in oklab, var(--panel2) 86%, transparent)
);
box-shadow: var(--shadow-px);
padding: 18px 16px 16px;
}
@@ -428,7 +481,8 @@ body::after {
.terminal__footer {
margin-top: 22px;
padding-top: 16px;
border-top: 1px solid color-mix(in oklab, var(--frame-border) 20%, transparent);
border-top: 1px solid
color-mix(in oklab, var(--frame-border) 20%, transparent);
color: var(--muted);
font-size: 13px;
}

View File

@@ -19,7 +19,9 @@ function safeSet(key, value) {
function preferredTheme() {
const stored = safeGet(THEME_STORAGE_KEY);
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function applyTheme(theme) {
@@ -28,12 +30,14 @@ function applyTheme(theme) {
const toggle = document.querySelector("[data-theme-toggle]");
const label = document.querySelector("[data-theme-label]");
if (toggle instanceof HTMLButtonElement) toggle.setAttribute("aria-pressed", theme === "dark" ? "true" : "false");
if (toggle instanceof HTMLButtonElement)
toggle.setAttribute("aria-pressed", theme === "dark" ? "true" : "false");
if (label) label.textContent = theme === "dark" ? "dark" : "light";
}
function toggleTheme() {
const current = document.documentElement.dataset.theme === "dark" ? "dark" : "light";
const current =
document.documentElement.dataset.theme === "dark" ? "dark" : "light";
const next = current === "dark" ? "light" : "dark";
safeSet(THEME_STORAGE_KEY, next);
applyTheme(next);
@@ -43,7 +47,8 @@ applyTheme(preferredTheme());
document.addEventListener("click", (event) => {
const target = event.target;
const button = target instanceof Element ? target.closest("[data-theme-toggle]") : null;
const button =
target instanceof Element ? target.closest("[data-theme-toggle]") : null;
if (button) toggleTheme();
});

View File

@@ -243,7 +243,11 @@ Recurring, isolated job with delivery:
```json
{
"name": "Morning brief",
"schedule": { "kind": "cron", "expr": "0 7 * * *", "tz": "America/Los_Angeles" },
"schedule": {
"kind": "cron",
"expr": "0 7 * * *",
"tz": "America/Los_Angeles"
},
"sessionTarget": "isolated",
"wakeMode": "next-heartbeat",
"payload": {

View File

@@ -92,7 +92,9 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
},
agents: {
defaults: {
model: { primary: "amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0" },
model: {
primary: "amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0",
},
},
},
}

View File

@@ -143,7 +143,11 @@ Agents process in order (one waits for previous to finish):
},
"broadcast": {
"strategy": "parallel",
"120363403215116621@g.us": ["code-reviewer", "security-auditor", "docs-generator"],
"120363403215116621@g.us": [
"code-reviewer",
"security-auditor",
"docs-generator"
],
"120363424282127706@g.us": ["support-en", "support-de"],
"+15555550123": ["assistant", "logger"]
}
@@ -289,7 +293,10 @@ Broadcast groups work alongside existing routing:
{
"bindings": [
{
"match": { "channel": "whatsapp", "peer": { "kind": "group", "id": "GROUP_A" } },
"match": {
"channel": "whatsapp",
"peer": { "kind": "group", "id": "GROUP_A" }
},
"agentId": "alfred"
}
],
@@ -366,7 +373,11 @@ tail -f ~/.openclaw/logs/gateway.log | grep broadcast
"workspace": "~/agents/testing",
"tools": { "allow": ["read", "exec"] }
},
{ "id": "docs-checker", "workspace": "~/agents/docs", "tools": { "allow": ["read"] } }
{
"id": "docs-checker",
"workspace": "~/agents/docs",
"tools": { "allow": ["read"] }
}
]
}
}

View File

@@ -123,8 +123,16 @@ Mattermost supports multiple accounts under `channels.mattermost.accounts`:
channels: {
mattermost: {
accounts: {
default: { name: "Primary", botToken: "mm-token", baseUrl: "https://chat.example.com" },
alerts: { name: "Alerts", botToken: "mm-token-2", baseUrl: "https://alerts.example.com" },
default: {
name: "Primary",
botToken: "mm-token",
baseUrl: "https://chat.example.com",
},
alerts: {
name: "Alerts",
botToken: "mm-token-2",
baseUrl: "https://alerts.example.com",
},
},
},
},

View File

@@ -151,7 +151,11 @@ Defaults: `relay.damus.io` and `nos.lol`.
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"relays": ["wss://relay.damus.io", "wss://relay.primal.net", "wss://nostr.wine"]
"relays": [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nostr.wine"
]
}
}
}

View File

@@ -80,11 +80,20 @@ Example:
```json5
{
agents: {
list: [{ id: "support", name: "Support", workspace: "~/.openclaw/workspace-support" }],
list: [
{
id: "support",
name: "Support",
workspace: "~/.openclaw/workspace-support",
},
],
},
bindings: [
{ match: { channel: "slack", teamId: "T123" }, agentId: "support" },
{ match: { channel: "telegram", peer: { kind: "group", id: "-100123" } }, agentId: "support" },
{
match: { channel: "telegram", peer: { kind: "group", id: "-100123" } },
agentId: "support",
},
],
}
```

View File

@@ -84,7 +84,14 @@ Example (DMs on host, groups sandboxed + messaging-only tools):
tools: {
// If allow is non-empty, everything else is blocked (deny still wins).
allow: ["group:messaging", "group:sessions"],
deny: ["group:runtime", "group:fs", "group:ui", "nodes", "cron", "gateway"],
deny: [
"group:runtime",
"group:fs",
"group:ui",
"nodes",
"cron",
"gateway",
],
},
},
},

View File

@@ -97,8 +97,14 @@ Example:
],
},
bindings: [
{ agentId: "alex", match: { channel: "whatsapp", peer: { kind: "dm", id: "+15551230001" } } },
{ agentId: "mia", match: { channel: "whatsapp", peer: { kind: "dm", id: "+15551230002" } } },
{
agentId: "alex",
match: { channel: "whatsapp", peer: { kind: "dm", id: "+15551230001" } },
},
{
agentId: "mia",
match: { channel: "whatsapp", peer: { kind: "dm", id: "+15551230002" } },
},
],
channels: {
whatsapp: {
@@ -260,7 +266,10 @@ Keep WhatsApp on the fast agent, but route one DM to Opus:
],
},
bindings: [
{ agentId: "opus", match: { channel: "whatsapp", peer: { kind: "dm", id: "+15551234567" } } },
{
agentId: "opus",
match: { channel: "whatsapp", peer: { kind: "dm", id: "+15551234567" } },
},
{ agentId: "chat", match: { channel: "whatsapp" } },
],
}
@@ -299,7 +308,15 @@ and a tighter tool policy:
"sessions_spawn",
"session_status",
],
deny: ["write", "edit", "apply_patch", "browser", "canvas", "nodes", "cron"],
deny: [
"write",
"edit",
"apply_patch",
"browser",
"canvas",
"nodes",
"cron",
],
},
},
],

View File

@@ -122,7 +122,11 @@ Hello-ok response:
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1048576, "maxBufferedBytes": 1048576, "tickIntervalMs": 30000 }
"policy": {
"maxPayload": 1048576,
"maxBufferedBytes": 1048576,
"tickIntervalMs": 30000
}
}
}
```
@@ -222,7 +226,9 @@ export type SystemEchoResult = Static<typeof SystemEchoResultSchema>;
In `src/gateway/protocol/index.ts`, export an AJV validator:
```ts
export const validateSystemEchoParams = ajv.compile<SystemEchoParams>(SystemEchoParamsSchema);
export const validateSystemEchoParams = ajv.compile<SystemEchoParams>(
SystemEchoParamsSchema,
);
```
3. **Server behavior**

View File

@@ -998,7 +998,13 @@
},
{
"group": "Web & Interfaces",
"pages": ["web/index", "web/control-ui", "web/dashboard", "web/webchat", "tui"]
"pages": [
"web/index",
"web/control-ui",
"web/dashboard",
"web/webchat",
"tui"
]
},
{
"group": "Channels",
@@ -1176,7 +1182,11 @@
},
{
"group": "Help",
"pages": ["zh-CN/help/index", "zh-CN/help/troubleshooting", "zh-CN/help/faq"]
"pages": [
"zh-CN/help/index",
"zh-CN/help/troubleshooting",
"zh-CN/help/faq"
]
},
{
"group": "Install & Updates",

View File

@@ -67,7 +67,11 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
// Auth profile metadata (secrets live in auth-profiles.json)
auth: {
profiles: {
"anthropic:me@example.com": { provider: "anthropic", mode: "oauth", email: "me@example.com" },
"anthropic:me@example.com": {
provider: "anthropic",
mode: "oauth",
email: "me@example.com",
},
"anthropic:work": { provider: "anthropic", mode: "api_key" },
"openai:default": { provider: "openai", mode: "api_key" },
"openai-codex:default": { provider: "openai-codex", mode: "oauth" },
@@ -163,7 +167,9 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
typingIntervalSeconds: 5,
sendPolicy: {
default: "allow",
rules: [{ action: "deny", match: { channel: "discord", chatType: "group" } }],
rules: [
{ action: "deny", match: { channel: "discord", chatType: "group" } },
],
},
},
@@ -368,7 +374,10 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
to: "+15555550123",
thinking: "low",
timeoutSeconds: 300,
transform: { module: "./transforms/gmail.js", export: "transformGmail" },
transform: {
module: "./transforms/gmail.js",
export: "transformGmail",
},
},
],
gmail: {

View File

@@ -235,12 +235,20 @@ Included files can themselves contain `$include` directives (up to 10 levels dee
sandbox: { mode: "all", scope: "session" },
},
// Merge agent lists from all clients
list: { $include: ["./clients/mueller/agents.json5", "./clients/schmidt/agents.json5"] },
list: {
$include: [
"./clients/mueller/agents.json5",
"./clients/schmidt/agents.json5",
],
},
},
// Merge broadcast configs
broadcast: {
$include: ["./clients/mueller/broadcast.json5", "./clients/schmidt/broadcast.json5"],
$include: [
"./clients/mueller/broadcast.json5",
"./clients/schmidt/broadcast.json5",
],
},
channels: { whatsapp: { groupPolicy: "allowlist" } },
@@ -392,7 +400,11 @@ rotation order used for failover.
{
auth: {
profiles: {
"anthropic:me@example.com": { provider: "anthropic", mode: "oauth", email: "me@example.com" },
"anthropic:me@example.com": {
provider: "anthropic",
mode: "oauth",
email: "me@example.com",
},
"anthropic:work": { provider: "anthropic", mode: "api_key" },
},
order: {
@@ -604,7 +616,9 @@ Group messages default to **require mention** (either metadata mention or regex
groupChat: { historyLimit: 50 },
},
agents: {
list: [{ id: "main", groupChat: { mentionPatterns: ["@openclaw", "openclaw"] } }],
list: [
{ id: "main", groupChat: { mentionPatterns: ["@openclaw", "openclaw"] } },
],
},
}
```
@@ -642,8 +656,14 @@ Per-agent override (takes precedence when set, even `[]`):
{
agents: {
list: [
{ id: "work", groupChat: { mentionPatterns: ["@workbot", "\\+15555550123"] } },
{ id: "personal", groupChat: { mentionPatterns: ["@homebot", "\\+15555550999"] } },
{
id: "work",
groupChat: { mentionPatterns: ["@workbot", "\\+15555550123"] },
},
{
id: "personal",
groupChat: { mentionPatterns: ["@homebot", "\\+15555550999"] },
},
],
},
}
@@ -1368,7 +1388,10 @@ Signal reactions can emit system events (shared reaction tooling):
channels: {
signal: {
reactionNotifications: "own", // off | own | all | allowlist
reactionAllowlist: ["+15551234567", "uuid:123e4567-e89b-12d3-a456-426614174000"],
reactionAllowlist: [
"+15551234567",
"uuid:123e4567-e89b-12d3-a456-426614174000",
],
historyLimit: 50, // include last N group messages as context (0 disables)
},
},
@@ -1897,7 +1920,10 @@ Example (adaptive tuned):
hardClearRatio: 0.5,
minPrunableToolChars: 50000,
softTrim: { maxChars: 4000, headChars: 1500, tailChars: 1500 },
hardClear: { enabled: true, placeholder: "[Old tool result content cleared]" },
hardClear: {
enabled: true,
placeholder: "[Old tool result content cleared]",
},
// Optional: restrict pruning to specific tools (deny wins; supports "*" wildcards)
tools: { deny: ["browser", "canvas"] },
},
@@ -1960,7 +1986,9 @@ Block streaming:
Example:
```json5
{
agents: { defaults: { blockStreamingChunk: { minChars: 800, maxChars: 1200 } } },
agents: {
defaults: { blockStreamingChunk: { minChars: 800, maxChars: 1200 } },
},
}
```
- `agents.defaults.blockStreamingCoalesce`: merge streamed blocks before sending.
@@ -2094,7 +2122,11 @@ Example:
},
models: [
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
{ type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] },
{
type: "cli",
command: "whisper",
args: ["--model", "base", "{{MediaPath}}"],
},
],
},
video: {
@@ -2322,7 +2354,10 @@ For package installs, ensure network egress, a writable root FS, and a root user
apparmorProfile: "openclaw-sandbox",
dns: ["1.1.1.1", "8.8.8.8"],
extraHosts: ["internal.service:10.0.0.5"],
binds: ["/var/run/docker.sock:/var/run/docker.sock", "/home/user/source:/source:rw"],
binds: [
"/var/run/docker.sock:/var/run/docker.sock",
"/home/user/source:/source:rw",
],
},
browser: {
enabled: false,
@@ -2584,7 +2619,9 @@ Use Synthetic's Anthropic-compatible endpoint:
agents: {
defaults: {
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } },
models: {
"synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" },
},
},
},
models: {
@@ -2747,7 +2784,9 @@ Controls session scoping, reset policy, reset triggers, and where the session st
maxPingPongTurns: 5,
},
sendPolicy: {
rules: [{ action: "deny", match: { channel: "discord", chatType: "group" } }],
rules: [
{ action: "deny", match: { channel: "discord", chatType: "group" } },
],
default: "allow",
},
},
@@ -2804,7 +2843,10 @@ Example:
skills: {
allowBundled: ["gemini", "peekaboo"],
load: {
extraDirs: ["~/Projects/agent-scripts/skills", "~/Projects/oss/some-skill-pack/skills"],
extraDirs: [
"~/Projects/agent-scripts/skills",
"~/Projects/oss/some-skill-pack/skills",
],
},
install: {
preferBrew: true,

View File

@@ -222,7 +222,12 @@ Defaults can be tuned under `gateway.http.endpoints.responses`:
},
images: {
allowUrl: true,
allowedMimes: ["image/jpeg", "image/png", "image/gif", "image/webp"],
allowedMimes: [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
],
maxBytes: 10485760,
maxRedirects: 3,
timeoutMs: 10000,

View File

@@ -73,7 +73,11 @@ Gateway → Client:
"type": "res",
"id": "…",
"ok": true,
"payload": { "type": "hello-ok", "protocol": 3, "policy": { "tickIntervalMs": 15000 } }
"payload": {
"type": "hello-ok",
"protocol": 3,
"policy": { "tickIntervalMs": 15000 }
}
}
```
@@ -108,7 +112,12 @@ When a device token is issued, `hello-ok` also includes:
"role": "node",
"scopes": [],
"caps": ["camera", "canvas", "screen", "location", "voice"],
"commands": ["camera.snap", "canvas.navigate", "screen.record", "location.get"],
"commands": [
"camera.snap",
"canvas.navigate",
"screen.record",
"location.get"
],
"permissions": { "camera.capture": true, "screen.record": false },
"auth": { "token": "…" },
"locale": "en-US",

View File

@@ -79,7 +79,10 @@ Example (read-only source + docker socket):
defaults: {
sandbox: {
docker: {
binds: ["/home/user/source:/source:ro", "/var/run/docker.sock:/var/run/docker.sock"],
binds: [
"/home/user/source:/source:ro",
"/var/run/docker.sock:/var/run/docker.sock",
],
},
},
},

View File

@@ -1100,7 +1100,11 @@ Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wra
---
name: imsg
description: iMessage/SMS CLI for listing chats, history, watch, and sending.
metadata: { "openclaw": { "os": ["darwin", "linux"], "requires": { "bins": ["imsg"] } } }
metadata:
{
"openclaw":
{ "os": ["darwin", "linux"], "requires": { "bins": ["imsg"] } },
}
---
```
4. Start a new session so the skills snapshot refreshes.

View File

@@ -130,7 +130,14 @@ name: my-hook
description: "Short description of what this hook does"
homepage: https://docs.openclaw.ai/hooks#my-hook
metadata:
{ "openclaw": { "emoji": "🔗", "events": ["command:new"], "requires": { "bins": ["node"] } } }
{
"openclaw":
{
"emoji": "🔗",
"events": ["command:new"],
"requires": { "bins": ["node"] },
},
}
---
# My Hook
@@ -612,7 +619,10 @@ const handler: HookHandler = async (event) => {
try {
await riskyOperation(event);
} catch (err) {
console.error("[my-handler] Failed:", err instanceof Error ? err.message : String(err));
console.error(
"[my-handler] Failed:",
err instanceof Error ? err.message : String(err),
);
// Don't throw - let other handlers run
}
};

View File

@@ -324,7 +324,12 @@ Legacy `agent.*` configs are migrated by `openclaw doctor`; prefer `agents.defau
```json
{
"tools": {
"allow": ["sessions_list", "sessions_send", "sessions_history", "session_status"],
"allow": [
"sessions_list",
"sessions_send",
"sessions_history",
"session_status"
],
"deny": ["exec", "write", "edit", "apply_patch", "read", "browser"]
}
}

View File

@@ -264,7 +264,10 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
`splitSdkTools()` passes all tools via `customTools`:
```typescript
export function splitSdkTools(options: { tools: AnyAgentTool[]; sandboxEnabled: boolean }) {
export function splitSdkTools(options: {
tools: AnyAgentTool[];
sandboxEnabled: boolean;
}) {
return {
builtInTools: [], // Empty. We override everything
customTools: toToolDefinitions(options.tools),
@@ -328,8 +331,15 @@ const compactResult = await compactEmbeddedPiSessionDirect({
OpenClaw maintains an auth profile store with multiple API keys per provider:
```typescript
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const profileOrder = resolveAuthProfileOrder({ cfg, store: authStore, provider, preferredProfile });
const authStore = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const profileOrder = resolveAuthProfileOrder({
cfg,
store: authStore,
provider,
preferredProfile,
});
```
Profiles rotate on failures with cooldown tracking:
@@ -409,7 +419,9 @@ if (cfg?.agents?.defaults?.contextPruning?.mode === "cache-ttl") {
`EmbeddedBlockChunker` manages streaming text into discrete reply blocks:
```typescript
const blockChunker = blockChunking ? new EmbeddedBlockChunker(blockChunking) : null;
const blockChunker = blockChunking
? new EmbeddedBlockChunker(blockChunking)
: null;
```
### Thinking/Final Tag Stripping
@@ -417,7 +429,10 @@ const blockChunker = blockChunking ? new EmbeddedBlockChunker(blockChunking) : n
Streaming output is processed to strip `<think>`/`<thinking>` blocks and extract `<final>` content:
```typescript
const stripBlockTags = (text: string, state: { thinking: boolean; final: boolean }) => {
const stripBlockTags = (
text: string,
state: { thinking: boolean; final: boolean },
) => {
// Strip <think>...</think> content
// If enforceFinalTag, only return <final>...</final> content
};
@@ -428,7 +443,12 @@ const stripBlockTags = (text: string, state: { thinking: boolean; final: boolean
Reply directives like `[[media:url]]`, `[[voice]]`, `[[reply:id]]` are parsed and extracted:
```typescript
const { text: cleanedText, mediaUrls, audioAsVoice, replyToId } = consumeReplyDirectives(chunk);
const {
text: cleanedText,
mediaUrls,
audioAsVoice,
replyToId,
} = consumeReplyDirectives(chunk);
```
## Error Handling

View File

@@ -397,7 +397,8 @@ const myChannel = {
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: (cfg) => Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
listAccountIds: (cfg) =>
Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
resolveAccount: (cfg, accountId) =>
cfg.channels?.acmechat?.accounts?.[accountId ?? "default"] ?? {
accountId,
@@ -484,7 +485,8 @@ const plugin = {
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: (cfg) => Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
listAccountIds: (cfg) =>
Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
resolveAccount: (cfg, accountId) =>
cfg.channels?.acmechat?.accounts?.[accountId ?? "default"] ?? {
accountId,

View File

@@ -34,7 +34,9 @@ synthetic/hf:MiniMaxAI/MiniMax-M2.1
agents: {
defaults: {
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } },
models: {
"synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" },
},
},
},
models: {

View File

@@ -16,7 +16,11 @@ and you configure everything via the `/setup` web wizard.
## One-click deploy
<a href="https://railway.com/deploy/clawdbot-railway-template" target="_blank" rel="noreferrer">
<a
href="https://railway.com/deploy/clawdbot-railway-template"
target="_blank"
rel="noreferrer"
>
Deploy on Railway
</a>

View File

@@ -49,7 +49,11 @@ export type PluginRuntime = {
channel: {
text: {
chunkMarkdownText(text: string, limit: number): string[];
resolveTextChunkLimit(cfg: OpenClawConfig, channel: string, accountId?: string): number;
resolveTextChunkLimit(
cfg: OpenClawConfig,
channel: string,
accountId?: string,
): number;
hasControlCommand(text: string, cfg: OpenClawConfig): boolean;
};
reply: {
@@ -76,7 +80,11 @@ export type PluginRuntime = {
}): { sessionKey: string; accountId: string };
};
pairing: {
buildPairingReply(params: { channel: string; idLine: string; code: string }): string;
buildPairingReply(params: {
channel: string;
idLine: string;
code: string;
}): string;
readAllowFromStore(channel: string): Promise<string[]>;
upsertPairingRequest(params: {
channel: string;
@@ -85,7 +93,9 @@ export type PluginRuntime = {
}): Promise<{ code: string; created: boolean }>;
};
media: {
fetchRemoteMedia(params: { url: string }): Promise<{ buffer: Buffer; contentType?: string }>;
fetchRemoteMedia(params: {
url: string;
}): Promise<{ buffer: Buffer; contentType?: string }>;
saveMediaBuffer(
buffer: Uint8Array,
contentType: string | undefined,

View File

@@ -154,7 +154,12 @@ Submit (send CR only):
Paste (bracketed by default):
```json
{ "tool": "process", "action": "paste", "sessionId": "<id>", "text": "line1\nline2\n" }
{
"tool": "process",
"action": "paste",
"sessionId": "<id>",
"text": "line1\nline2\n"
}
```
## apply_patch (experimental)

View File

@@ -15,7 +15,10 @@ All skills-related configuration lives under `skills` in `~/.openclaw/openclaw.j
skills: {
allowBundled: ["gemini", "peekaboo"],
load: {
extraDirs: ["~/Projects/agent-scripts/skills", "~/Projects/oss/some-skill-pack/skills"],
extraDirs: [
"~/Projects/agent-scripts/skills",
"~/Projects/oss/some-skill-pack/skills",
],
watch: true,
watchDebounceMs: 250,
},

View File

@@ -114,7 +114,12 @@ metadata:
{
"openclaw":
{
"requires": { "bins": ["uv"], "env": ["GEMINI_API_KEY"], "config": ["browser.enabled"] },
"requires":
{
"bins": ["uv"],
"env": ["GEMINI_API_KEY"],
"config": ["browser.enabled"],
},
"primaryEnv": "GEMINI_API_KEY",
},
}

View File

@@ -234,7 +234,11 @@ Telegram 通过 `message_thread_id` 支持论坛主题。对于定时任务投
```json
{
"name": "Morning brief",
"schedule": { "kind": "cron", "expr": "0 7 * * *", "tz": "America/Los_Angeles" },
"schedule": {
"kind": "cron",
"expr": "0 7 * * *",
"tz": "America/Los_Angeles"
},
"sessionTarget": "isolated",
"wakeMode": "next-heartbeat",
"payload": {

View File

@@ -94,7 +94,9 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
},
agents: {
defaults: {
model: { primary: "amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0" },
model: {
primary: "amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0",
},
},
},
}

View File

@@ -150,7 +150,11 @@ x-i18n:
},
"broadcast": {
"strategy": "parallel",
"120363403215116621@g.us": ["code-reviewer", "security-auditor", "docs-generator"],
"120363403215116621@g.us": [
"code-reviewer",
"security-auditor",
"docs-generator"
],
"120363424282127706@g.us": ["support-en", "support-de"],
"+15555550123": ["assistant", "logger"]
}
@@ -296,7 +300,10 @@ x-i18n:
{
"bindings": [
{
"match": { "channel": "whatsapp", "peer": { "kind": "group", "id": "GROUP_A" } },
"match": {
"channel": "whatsapp",
"peer": { "kind": "group", "id": "GROUP_A" }
},
"agentId": "alfred"
}
],
@@ -373,7 +380,11 @@ tail -f ~/.openclaw/logs/gateway.log | grep broadcast
"workspace": "~/agents/testing",
"tools": { "allow": ["read", "exec"] }
},
{ "id": "docs-checker", "workspace": "~/agents/docs", "tools": { "allow": ["read"] } }
{
"id": "docs-checker",
"workspace": "~/agents/docs",
"tools": { "allow": ["read"] }
}
]
}
}

View File

@@ -127,8 +127,16 @@ Mattermost 支持在 `channels.mattermost.accounts` 下配置多个账户:
channels: {
mattermost: {
accounts: {
default: { name: "Primary", botToken: "mm-token", baseUrl: "https://chat.example.com" },
alerts: { name: "Alerts", botToken: "mm-token-2", baseUrl: "https://alerts.example.com" },
default: {
name: "Primary",
botToken: "mm-token",
baseUrl: "https://chat.example.com",
},
alerts: {
name: "Alerts",
botToken: "mm-token-2",
baseUrl: "https://alerts.example.com",
},
},
},
},

View File

@@ -158,7 +158,11 @@ export NOSTR_PRIVATE_KEY="nsec1..."
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"relays": ["wss://relay.damus.io", "wss://relay.primal.net", "wss://nostr.wine"]
"relays": [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nostr.wine"
]
}
}
}

View File

@@ -85,11 +85,20 @@ OpenClaw 将回复**路由回消息来源的渠道**。模型不会选择渠道
```json5
{
agents: {
list: [{ id: "support", name: "Support", workspace: "~/.openclaw/workspace-support" }],
list: [
{
id: "support",
name: "Support",
workspace: "~/.openclaw/workspace-support",
},
],
},
bindings: [
{ match: { channel: "slack", teamId: "T123" }, agentId: "support" },
{ match: { channel: "telegram", peer: { kind: "group", id: "-100123" } }, agentId: "support" },
{
match: { channel: "telegram", peer: { kind: "group", id: "-100123" } },
agentId: "support",
},
],
}
```

View File

@@ -91,7 +91,14 @@ otherwise -> reply
tools: {
// If allow is non-empty, everything else is blocked (deny still wins).
allow: ["group:messaging", "group:sessions"],
deny: ["group:runtime", "group:fs", "group:ui", "nodes", "cron", "gateway"],
deny: [
"group:runtime",
"group:fs",
"group:ui",
"nodes",
"cron",
"gateway",
],
},
},
},

View File

@@ -98,8 +98,14 @@ openclaw agents list --bindings
],
},
bindings: [
{ agentId: "alex", match: { channel: "whatsapp", peer: { kind: "dm", id: "+15551230001" } } },
{ agentId: "mia", match: { channel: "whatsapp", peer: { kind: "dm", id: "+15551230002" } } },
{
agentId: "alex",
match: { channel: "whatsapp", peer: { kind: "dm", id: "+15551230001" } },
},
{
agentId: "mia",
match: { channel: "whatsapp", peer: { kind: "dm", id: "+15551230002" } },
},
],
channels: {
whatsapp: {
@@ -259,7 +265,10 @@ openclaw agents list --bindings
],
},
bindings: [
{ agentId: "opus", match: { channel: "whatsapp", peer: { kind: "dm", id: "+15551234567" } } },
{
agentId: "opus",
match: { channel: "whatsapp", peer: { kind: "dm", id: "+15551234567" } },
},
{ agentId: "chat", match: { channel: "whatsapp" } },
],
}
@@ -297,7 +306,15 @@ openclaw agents list --bindings
"sessions_spawn",
"session_status",
],
deny: ["write", "edit", "apply_patch", "browser", "canvas", "nodes", "cron"],
deny: [
"write",
"edit",
"apply_patch",
"browser",
"canvas",
"nodes",
"cron",
],
},
},
],

View File

@@ -120,7 +120,11 @@ Hello-ok 响应:
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1048576, "maxBufferedBytes": 1048576, "tickIntervalMs": 30000 }
"policy": {
"maxPayload": 1048576,
"maxBufferedBytes": 1048576,
"tickIntervalMs": 30000
}
}
}
```
@@ -220,7 +224,9 @@ export type SystemEchoResult = Static<typeof SystemEchoResultSchema>;
`src/gateway/protocol/index.ts` 中导出 AJV 验证器:
```ts
export const validateSystemEchoParams = ajv.compile<SystemEchoParams>(SystemEchoParamsSchema);
export const validateSystemEchoParams = ajv.compile<SystemEchoParams>(
SystemEchoParamsSchema,
);
```
3. **服务器行为**

View File

@@ -74,7 +74,11 @@ x-i18n:
// 认证配置文件元数据(密钥存放在 auth-profiles.json 中)
auth: {
profiles: {
"anthropic:me@example.com": { provider: "anthropic", mode: "oauth", email: "me@example.com" },
"anthropic:me@example.com": {
provider: "anthropic",
mode: "oauth",
email: "me@example.com",
},
"anthropic:work": { provider: "anthropic", mode: "api_key" },
"openai:default": { provider: "openai", mode: "api_key" },
"openai-codex:default": { provider: "openai-codex", mode: "oauth" },
@@ -170,7 +174,9 @@ x-i18n:
typingIntervalSeconds: 5,
sendPolicy: {
default: "allow",
rules: [{ action: "deny", match: { channel: "discord", chatType: "group" } }],
rules: [
{ action: "deny", match: { channel: "discord", chatType: "group" } },
],
},
},
@@ -375,7 +381,10 @@ x-i18n:
to: "+15555550123",
thinking: "low",
timeoutSeconds: 300,
transform: { module: "./transforms/gmail.js", export: "transformGmail" },
transform: {
module: "./transforms/gmail.js",
export: "transformGmail",
},
},
],
gmail: {

View File

@@ -241,12 +241,20 @@ scripts/sandbox-setup.sh
sandbox: { mode: "all", scope: "session" },
},
// 合并所有客户的智能体列表
list: { $include: ["./clients/mueller/agents.json5", "./clients/schmidt/agents.json5"] },
list: {
$include: [
"./clients/mueller/agents.json5",
"./clients/schmidt/agents.json5",
],
},
},
// 合并广播配置
broadcast: {
$include: ["./clients/mueller/broadcast.json5", "./clients/schmidt/broadcast.json5"],
$include: [
"./clients/mueller/broadcast.json5",
"./clients/schmidt/broadcast.json5",
],
},
channels: { whatsapp: { groupPolicy: "allowlist" } },
@@ -394,7 +402,11 @@ OpenClaw 在以下位置存储**每个智能体的**认证配置文件OAuth +
{
auth: {
profiles: {
"anthropic:me@example.com": { provider: "anthropic", mode: "oauth", email: "me@example.com" },
"anthropic:me@example.com": {
provider: "anthropic",
mode: "oauth",
email: "me@example.com",
},
"anthropic:work": { provider: "anthropic", mode: "api_key" },
},
order: {
@@ -606,7 +618,9 @@ OpenClaw 在以下位置存储**每个智能体的**认证配置文件OAuth +
groupChat: { historyLimit: 50 },
},
agents: {
list: [{ id: "main", groupChat: { mentionPatterns: ["@openclaw", "openclaw"] } }],
list: [
{ id: "main", groupChat: { mentionPatterns: ["@openclaw", "openclaw"] } },
],
},
}
```
@@ -644,8 +658,14 @@ OpenClaw 在以下位置存储**每个智能体的**认证配置文件OAuth +
{
agents: {
list: [
{ id: "work", groupChat: { mentionPatterns: ["@workbot", "\\+15555550123"] } },
{ id: "personal", groupChat: { mentionPatterns: ["@homebot", "\\+15555550999"] } },
{
id: "work",
groupChat: { mentionPatterns: ["@workbot", "\\+15555550123"] },
},
{
id: "personal",
groupChat: { mentionPatterns: ["@homebot", "\\+15555550999"] },
},
],
},
}
@@ -1365,7 +1385,10 @@ Signal 反应可以发出系统事件(共享反应工具):
channels: {
signal: {
reactionNotifications: "own", // off | own | all | allowlist
reactionAllowlist: ["+15551234567", "uuid:123e4567-e89b-12d3-a456-426614174000"],
reactionAllowlist: [
"+15551234567",
"uuid:123e4567-e89b-12d3-a456-426614174000",
],
historyLimit: 50, // 包含最近 N 条群消息作为上下文0 禁用)
},
},
@@ -1871,7 +1894,10 @@ MiniMax 认证:设置 `MINIMAX_API_KEY`(环境变量)或配置 `models.pro
hardClearRatio: 0.5,
minPrunableToolChars: 50000,
softTrim: { maxChars: 4000, headChars: 1500, tailChars: 1500 },
hardClear: { enabled: true, placeholder: "[Old tool result content cleared]" },
hardClear: {
enabled: true,
placeholder: "[Old tool result content cleared]",
},
// 可选限制裁剪仅针对特定工具deny 优先;支持 "*" 通配符)
tools: { deny: ["browser", "canvas"] },
},
@@ -1928,7 +1954,9 @@ MiniMax 认证:设置 `MINIMAX_API_KEY`(环境变量)或配置 `models.pro
示例:
```json5
{
agents: { defaults: { blockStreamingChunk: { minChars: 800, maxChars: 1200 } } },
agents: {
defaults: { blockStreamingChunk: { minChars: 800, maxChars: 1200 } },
},
}
```
- `agents.defaults.blockStreamingCoalesce`:发送前合并流式块。
@@ -2061,7 +2089,11 @@ Z.AI 模型可通过 `zai/<model>` 使用(例如 `zai/glm-4.7`),需要环
},
models: [
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
{ type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] },
{
type: "cli",
command: "whisper",
args: ["--model", "base", "{{MediaPath}}"],
},
],
},
video: {
@@ -2284,7 +2316,10 @@ Z.AI 模型可通过 `zai/<model>` 使用(例如 `zai/glm-4.7`),需要环
apparmorProfile: "openclaw-sandbox",
dns: ["1.1.1.1", "8.8.8.8"],
extraHosts: ["internal.service:10.0.0.5"],
binds: ["/var/run/docker.sock:/var/run/docker.sock", "/home/user/source:/source:rw"],
binds: [
"/var/run/docker.sock:/var/run/docker.sock",
"/home/user/source:/source:rw",
],
},
browser: {
enabled: false,
@@ -2541,7 +2576,9 @@ Z.AI 模型通过内置的 `zai` 提供商提供。在环境中设置 `ZAI_API_K
agents: {
defaults: {
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } },
models: {
"synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" },
},
},
},
models: {
@@ -2703,7 +2740,9 @@ Z.AI 模型通过内置的 `zai` 提供商提供。在环境中设置 `ZAI_API_K
maxPingPongTurns: 5,
},
sendPolicy: {
rules: [{ action: "deny", match: { channel: "discord", chatType: "group" } }],
rules: [
{ action: "deny", match: { channel: "discord", chatType: "group" } },
],
default: "allow",
},
},
@@ -2757,7 +2796,10 @@ Z.AI 模型通过内置的 `zai` 提供商提供。在环境中设置 `ZAI_API_K
skills: {
allowBundled: ["gemini", "peekaboo"],
load: {
extraDirs: ["~/Projects/agent-scripts/skills", "~/Projects/oss/some-skill-pack/skills"],
extraDirs: [
"~/Projects/agent-scripts/skills",
"~/Projects/oss/some-skill-pack/skills",
],
},
install: {
preferBrew: true,

View File

@@ -224,7 +224,12 @@ URL 获取默认值:
},
images: {
allowUrl: true,
allowedMimes: ["image/jpeg", "image/png", "image/gif", "image/webp"],
allowedMimes: [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
],
maxBytes: 10485760,
maxRedirects: 3,
timeoutMs: 10000,

View File

@@ -77,7 +77,11 @@ Gateway网关 → 客户端:
"type": "res",
"id": "…",
"ok": true,
"payload": { "type": "hello-ok", "protocol": 3, "policy": { "tickIntervalMs": 15000 } }
"payload": {
"type": "hello-ok",
"protocol": 3,
"policy": { "tickIntervalMs": 15000 }
}
}
```
@@ -112,7 +116,12 @@ Gateway网关 → 客户端:
"role": "node",
"scopes": [],
"caps": ["camera", "canvas", "screen", "location", "voice"],
"commands": ["camera.snap", "canvas.navigate", "screen.record", "location.get"],
"commands": [
"camera.snap",
"canvas.navigate",
"screen.record",
"location.get"
],
"permissions": { "camera.capture": true, "screen.record": false },
"auth": { "token": "…" },
"locale": "en-US",

View File

@@ -83,7 +83,10 @@ OpenClaw 会将符合条件的 Skills 镜像到沙箱工作区(`.../skills`
defaults: {
sandbox: {
docker: {
binds: ["/home/user/source:/source:ro", "/var/run/docker.sock:/var/run/docker.sock"],
binds: [
"/home/user/source:/source:ro",
"/var/run/docker.sock:/var/run/docker.sock",
],
},
},
},

View File

@@ -1033,7 +1033,11 @@ pnpm add -g clawhub
---
name: imsg
description: iMessage/SMS CLI for listing chats, history, watch, and sending.
metadata: { "openclaw": { "os": ["darwin", "linux"], "requires": { "bins": ["imsg"] } } }
metadata:
{
"openclaw":
{ "os": ["darwin", "linux"], "requires": { "bins": ["imsg"] } },
}
---
```
4. 开始新会话以刷新 Skills 快照。

View File

@@ -136,7 +136,14 @@ name: my-hook
description: "这个钩子做什么的简短描述"
homepage: https://docs.openclaw.ai/hooks#my-hook
metadata:
{ "openclaw": { "emoji": "🔗", "events": ["command:new"], "requires": { "bins": ["node"] } } }
{
"openclaw":
{
"emoji": "🔗",
"events": ["command:new"],
"requires": { "bins": ["node"] },
},
}
---
# My Hook
@@ -618,7 +625,10 @@ const handler: HookHandler = async (event) => {
try {
await riskyOperation(event);
} catch (err) {
console.error("[my-handler] Failed:", err instanceof Error ? err.message : String(err));
console.error(
"[my-handler] Failed:",
err instanceof Error ? err.message : String(err),
);
// 不要抛出异常 - 让其他处理器继续运行
}
};

View File

@@ -330,7 +330,12 @@ agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.*
```json
{
"tools": {
"allow": ["sessions_list", "sessions_send", "sessions_history", "session_status"],
"allow": [
"sessions_list",
"sessions_send",
"sessions_history",
"session_status"
],
"deny": ["exec", "write", "edit", "apply_patch", "read", "browser"]
}
}

View File

@@ -166,7 +166,11 @@ const result = await runEmbeddedPiAgent({
`runEmbeddedAttempt()`(由 `runEmbeddedPiAgent()` 调用)内部,使用 pi SDK
```typescript
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
import {
createAgentSession,
SessionManager,
SettingsManager,
} from "@mariozechner/pi-coding-agent";
const { session } = await createAgentSession({
cwd: resolvedWorkspace,
@@ -259,7 +263,10 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
`splitSdkTools()` 将所有工具通过 `customTools` 传递:
```typescript
export function splitSdkTools(options: { tools: AnyAgentTool[]; sandboxEnabled: boolean }) {
export function splitSdkTools(options: {
tools: AnyAgentTool[];
sandboxEnabled: boolean;
}) {
return {
builtInTools: [], // 空。我们覆盖所有内容
customTools: toToolDefinitions(options.tools),
@@ -323,8 +330,15 @@ const compactResult = await compactEmbeddedPiSessionDirect({
OpenClaw 维护一个认证配置文件存储,每个提供商可有多个 API 密钥:
```typescript
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const profileOrder = resolveAuthProfileOrder({ cfg, store: authStore, provider, preferredProfile });
const authStore = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const profileOrder = resolveAuthProfileOrder({
cfg,
store: authStore,
provider,
preferredProfile,
});
```
配置文件在失败时轮换,并带有冷却跟踪:
@@ -404,7 +418,9 @@ if (cfg?.agents?.defaults?.contextPruning?.mode === "cache-ttl") {
`EmbeddedBlockChunker` 管理将流式文本拆分为离散的回复块:
```typescript
const blockChunker = blockChunking ? new EmbeddedBlockChunker(blockChunking) : null;
const blockChunker = blockChunking
? new EmbeddedBlockChunker(blockChunking)
: null;
```
### 思考/最终标签剥离
@@ -412,7 +428,10 @@ const blockChunker = blockChunking ? new EmbeddedBlockChunker(blockChunking) : n
流式输出会被处理以剥离 `<think>`/`<thinking>` 块并提取 `<final>` 内容:
```typescript
const stripBlockTags = (text: string, state: { thinking: boolean; final: boolean }) => {
const stripBlockTags = (
text: string,
state: { thinking: boolean; final: boolean },
) => {
// 剥离 <think>...</think> 内容
// 如果 enforceFinalTag仅返回 <final>...</final> 内容
};
@@ -423,7 +442,12 @@ const stripBlockTags = (text: string, state: { thinking: boolean; final: boolean
`[[media:url]]``[[voice]]``[[reply:id]]` 等回复指令会被解析和提取:
```typescript
const { text: cleanedText, mediaUrls, audioAsVoice, replyToId } = consumeReplyDirectives(chunk);
const {
text: cleanedText,
mediaUrls,
audioAsVoice,
replyToId,
} = consumeReplyDirectives(chunk);
```
## 错误处理

View File

@@ -376,7 +376,8 @@ const myChannel = {
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: (cfg) => Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
listAccountIds: (cfg) =>
Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
resolveAccount: (cfg, accountId) =>
cfg.channels?.acmechat?.accounts?.[accountId ?? "default"] ?? {
accountId,
@@ -463,7 +464,8 @@ const plugin = {
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: (cfg) => Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
listAccountIds: (cfg) =>
Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
resolveAccount: (cfg, accountId) =>
cfg.channels?.acmechat?.accounts?.[accountId ?? "default"] ?? {
accountId,

View File

@@ -40,7 +40,9 @@ synthetic/hf:MiniMaxAI/MiniMax-M2.1
agents: {
defaults: {
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } },
models: {
"synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" },
},
},
},
models: {

View File

@@ -23,7 +23,11 @@ x-i18n:
## 一键部署
<a href="https://railway.com/deploy/clawdbot-railway-template" target="_blank" rel="noreferrer">
<a
href="https://railway.com/deploy/clawdbot-railway-template"
target="_blank"
rel="noreferrer"
>
Deploy on Railway
</a>

View File

@@ -56,7 +56,11 @@ export type PluginRuntime = {
channel: {
text: {
chunkMarkdownText(text: string, limit: number): string[];
resolveTextChunkLimit(cfg: OpenClawConfig, channel: string, accountId?: string): number;
resolveTextChunkLimit(
cfg: OpenClawConfig,
channel: string,
accountId?: string,
): number;
hasControlCommand(text: string, cfg: OpenClawConfig): boolean;
};
reply: {
@@ -83,7 +87,11 @@ export type PluginRuntime = {
}): { sessionKey: string; accountId: string };
};
pairing: {
buildPairingReply(params: { channel: string; idLine: string; code: string }): string;
buildPairingReply(params: {
channel: string;
idLine: string;
code: string;
}): string;
readAllowFromStore(channel: string): Promise<string[]>;
upsertPairingRequest(params: {
channel: string;
@@ -92,7 +100,9 @@ export type PluginRuntime = {
}): Promise<{ code: string; created: boolean }>;
};
media: {
fetchRemoteMedia(params: { url: string }): Promise<{ buffer: Buffer; contentType?: string }>;
fetchRemoteMedia(params: {
url: string;
}): Promise<{ buffer: Buffer; contentType?: string }>;
saveMediaBuffer(
buffer: Uint8Array,
contentType: string | undefined,

View File

@@ -143,7 +143,12 @@ openclaw config set agents.list[0].tools.exec.node "node-id-or-name"
粘贴(默认带括号标记):
```json
{ "tool": "process", "action": "paste", "sessionId": "<id>", "text": "line1\nline2\n" }
{
"tool": "process",
"action": "paste",
"sessionId": "<id>",
"text": "line1\nline2\n"
}
```
## apply_patch实验性

View File

@@ -22,7 +22,10 @@ x-i18n:
skills: {
allowBundled: ["gemini", "peekaboo"],
load: {
extraDirs: ["~/Projects/agent-scripts/skills", "~/Projects/oss/some-skill-pack/skills"],
extraDirs: [
"~/Projects/agent-scripts/skills",
"~/Projects/oss/some-skill-pack/skills",
],
watch: true,
watchDebounceMs: 250,
},

View File

@@ -107,7 +107,12 @@ metadata:
{
"openclaw":
{
"requires": { "bins": ["uv"], "env": ["GEMINI_API_KEY"], "config": ["browser.enabled"] },
"requires":
{
"bins": ["uv"],
"env": ["GEMINI_API_KEY"],
"config": ["browser.enabled"],
},
"primaryEnv": "GEMINI_API_KEY",
},
}

View File

@@ -1,6 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
import {
normalizeBlueBubblesServerUrl,
type BlueBubblesAccountConfig,
} from "./types.js";
export type ResolvedBlueBubblesAccount = {
accountId: string;
@@ -27,7 +30,9 @@ export function listBlueBubblesAccountIds(cfg: OpenClawConfig): string[] {
return ids.toSorted((a, b) => a.localeCompare(b));
}
export function resolveDefaultBlueBubblesAccountId(cfg: OpenClawConfig): string {
export function resolveDefaultBlueBubblesAccountId(
cfg: OpenClawConfig,
): string {
const ids = listBlueBubblesAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
@@ -70,7 +75,9 @@ export function resolveBlueBubblesAccount(params: {
const serverUrl = merged.serverUrl?.trim();
const password = merged.password?.trim();
const configured = Boolean(serverUrl && password);
const baseUrl = serverUrl ? normalizeBlueBubblesServerUrl(serverUrl) : undefined;
const baseUrl = serverUrl
? normalizeBlueBubblesServerUrl(serverUrl)
: undefined;
return {
accountId,
enabled: baseEnabled !== false && accountEnabled,
@@ -81,7 +88,9 @@ export function resolveBlueBubblesAccount(params: {
};
}
export function listEnabledBlueBubblesAccounts(cfg: OpenClawConfig): ResolvedBlueBubblesAccount[] {
export function listEnabledBlueBubblesAccounts(
cfg: OpenClawConfig,
): ResolvedBlueBubblesAccount[] {
return listBlueBubblesAccountIds(cfg)
.map((accountId) => resolveBlueBubblesAccount({ cfg, accountId }))
.filter((account) => account.enabled);

View File

@@ -19,7 +19,9 @@ vi.mock("./reactions.js", () => ({
}));
vi.mock("./send.js", () => ({
resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
resolveChatGuidForTarget: vi
.fn()
.mockResolvedValue("iMessage;-;+15551234567"),
sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }),
}));
@@ -34,7 +36,9 @@ vi.mock("./chat.js", () => ({
}));
vi.mock("./attachments.js", () => ({
sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
sendBlueBubblesAttachment: vi
.fn()
.mockResolvedValue({ messageId: "att-msg-123" }),
}));
vi.mock("./monitor.js", () => ({
@@ -98,25 +102,53 @@ describe("bluebubblesMessageActions", () => {
describe("supportsAction", () => {
it("returns true for react action", () => {
expect(bluebubblesMessageActions.supportsAction({ action: "react" })).toBe(true);
expect(
bluebubblesMessageActions.supportsAction({ action: "react" }),
).toBe(true);
});
it("returns true for all supported actions", () => {
expect(bluebubblesMessageActions.supportsAction({ action: "edit" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "unsend" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "reply" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "sendWithEffect" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "renameGroup" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "setGroupIcon" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "addParticipant" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "removeParticipant" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "leaveGroup" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "sendAttachment" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "edit" })).toBe(
true,
);
expect(
bluebubblesMessageActions.supportsAction({ action: "unsend" }),
).toBe(true);
expect(
bluebubblesMessageActions.supportsAction({ action: "reply" }),
).toBe(true);
expect(
bluebubblesMessageActions.supportsAction({ action: "sendWithEffect" }),
).toBe(true);
expect(
bluebubblesMessageActions.supportsAction({ action: "renameGroup" }),
).toBe(true);
expect(
bluebubblesMessageActions.supportsAction({ action: "setGroupIcon" }),
).toBe(true);
expect(
bluebubblesMessageActions.supportsAction({ action: "addParticipant" }),
).toBe(true);
expect(
bluebubblesMessageActions.supportsAction({
action: "removeParticipant",
}),
).toBe(true);
expect(
bluebubblesMessageActions.supportsAction({ action: "leaveGroup" }),
).toBe(true);
expect(
bluebubblesMessageActions.supportsAction({ action: "sendAttachment" }),
).toBe(true);
});
it("returns false for unsupported actions", () => {
expect(bluebubblesMessageActions.supportsAction({ action: "delete" })).toBe(false);
expect(bluebubblesMessageActions.supportsAction({ action: "unknown" })).toBe(false);
expect(
bluebubblesMessageActions.supportsAction({ action: "delete" }),
).toBe(false);
expect(
bluebubblesMessageActions.supportsAction({ action: "unknown" }),
).toBe(false);
});
});
@@ -302,7 +334,9 @@ describe("bluebubblesMessageActions", () => {
it("resolves chatGuid from to parameter", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15559876543");
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce(
"iMessage;-;+15559876543",
);
const cfg: OpenClawConfig = {
channels: {
@@ -364,7 +398,9 @@ describe("bluebubblesMessageActions", () => {
it("uses toolContext currentChannelId when no explicit target is provided", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15550001111");
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce(
"iMessage;-;+15550001111",
);
const cfg: OpenClawConfig = {
channels: {
@@ -402,7 +438,9 @@ describe("bluebubblesMessageActions", () => {
it("resolves short messageId before reacting", async () => {
const { resolveBlueBubblesMessageId } = await import("./monitor.js");
const { sendBlueBubblesReaction } = await import("./reactions.js");
vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce("resolved-uuid");
vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce(
"resolved-uuid",
);
const cfg: OpenClawConfig = {
channels: {
@@ -424,7 +462,9 @@ describe("bluebubblesMessageActions", () => {
accountId: null,
});
expect(resolveBlueBubblesMessageId).toHaveBeenCalledWith("1", { requireKnownShortId: true });
expect(resolveBlueBubblesMessageId).toHaveBeenCalledWith("1", {
requireKnownShortId: true,
});
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
messageGuid: "resolved-uuid",

View File

@@ -26,7 +26,10 @@ import { resolveBlueBubblesMessageId } from "./monitor.js";
import { isMacOS26OrHigher } from "./probe.js";
import { sendBlueBubblesReaction } from "./reactions.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
import {
normalizeBlueBubblesHandle,
parseBlueBubblesTarget,
} from "./targets.js";
const providerId = "bluebubbles";
@@ -52,7 +55,10 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
return readStringParam(params, "text") ?? readStringParam(params, "message");
}
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
function readBooleanParam(
params: Record<string, unknown>,
key: string,
): boolean | undefined {
const raw = params[key];
if (typeof raw === "boolean") {
return raw;
@@ -70,7 +76,9 @@ function readBooleanParam(params: Record<string, unknown>, key: string): boolean
}
/** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(
BLUEBUBBLES_ACTION_NAMES,
);
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
@@ -105,7 +113,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
if (!to) {
return null;
}
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
const accountId =
typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId };
},
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
@@ -144,15 +153,25 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
: null;
if (!target) {
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
throw new Error(
`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`,
);
}
if (!baseUrl || !password) {
throw new Error(`BlueBubbles ${action} requires serverUrl and password.`);
throw new Error(
`BlueBubbles ${action} requires serverUrl and password.`,
);
}
const resolved = await resolveChatGuidForTarget({ baseUrl, password, target });
const resolved = await resolveChatGuidForTarget({
baseUrl,
password,
target,
});
if (!resolved) {
throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`);
throw new Error(
`BlueBubbles ${action} failed: chatGuid not found for target.`,
);
}
return resolved;
};
@@ -160,7 +179,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
// Handle react action
if (action === "react") {
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
removeErrorMessage:
"Emoji is required to remove a BlueBubbles reaction.",
});
if (isEmpty && !remove) {
throw new Error(
@@ -175,7 +195,9 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
const messageId = resolveBlueBubblesMessageId(rawMessageId, {
requireKnownShortId: true,
});
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const resolvedChatGuid = await resolveChatGuid();
@@ -188,7 +210,10 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
opts,
});
return jsonResult({ ok: true, ...(remove ? { removed: true } : { added: emoji }) });
return jsonResult({
ok: true,
...(remove ? { removed: true } : { added: emoji }),
});
}
// Handle edit action
@@ -219,9 +244,14 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
const messageId = resolveBlueBubblesMessageId(rawMessageId, {
requireKnownShortId: true,
});
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
const backwardsCompatMessage = readStringParam(
params,
"backwardsCompatMessage",
);
await editBlueBubblesMessage(messageId, newText, {
...opts,
@@ -242,7 +272,9 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
const messageId = resolveBlueBubblesMessageId(rawMessageId, {
requireKnownShortId: true,
});
const partIndex = readNumberParam(params, "partIndex", { integer: true });
await unsendBlueBubblesMessage(messageId, {
@@ -257,7 +289,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
if (action === "reply") {
const rawMessageId = readStringParam(params, "messageId");
const text = readMessageText(params);
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
const to =
readStringParam(params, "to") ?? readStringParam(params, "target");
if (!rawMessageId || !text || !to) {
const missing: string[] = [];
if (!rawMessageId) {
@@ -275,7 +308,9 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
const messageId = resolveBlueBubblesMessageId(rawMessageId, {
requireKnownShortId: true,
});
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const result = await sendMessageBlueBubbles(to, text, {
@@ -284,14 +319,21 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
});
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: rawMessageId });
return jsonResult({
ok: true,
messageId: result.messageId,
repliedTo: rawMessageId,
});
}
// Handle sendWithEffect action
if (action === "sendWithEffect") {
const text = readMessageText(params);
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
const to =
readStringParam(params, "to") ?? readStringParam(params, "target");
const effectId =
readStringParam(params, "effectId") ??
readStringParam(params, "effect");
if (!text || !to || !effectId) {
const missing: string[] = [];
if (!text) {
@@ -316,15 +358,23 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
effectId,
});
return jsonResult({ ok: true, messageId: result.messageId, effect: effectId });
return jsonResult({
ok: true,
messageId: result.messageId,
effect: effectId,
});
}
// Handle renameGroup action
if (action === "renameGroup") {
const resolvedChatGuid = await resolveChatGuid();
const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name");
const displayName =
readStringParam(params, "displayName") ??
readStringParam(params, "name");
if (!displayName) {
throw new Error("BlueBubbles renameGroup requires displayName or name parameter.");
throw new Error(
"BlueBubbles renameGroup requires displayName or name parameter.",
);
}
await renameBlueBubblesChat(resolvedChatGuid, displayName, opts);
@@ -337,9 +387,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
const resolvedChatGuid = await resolveChatGuid();
const base64Buffer = readStringParam(params, "buffer");
const filename =
readStringParam(params, "filename") ?? readStringParam(params, "name") ?? "icon.png";
readStringParam(params, "filename") ??
readStringParam(params, "name") ??
"icon.png";
const contentType =
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
readStringParam(params, "contentType") ??
readStringParam(params, "mimeType");
if (!base64Buffer) {
throw new Error(
@@ -349,40 +402,62 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
}
// Decode base64 to buffer
const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
const buffer = Uint8Array.from(atob(base64Buffer), (c) =>
c.charCodeAt(0),
);
await setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, {
...opts,
contentType: contentType ?? undefined,
});
return jsonResult({ ok: true, chatGuid: resolvedChatGuid, iconSet: true });
return jsonResult({
ok: true,
chatGuid: resolvedChatGuid,
iconSet: true,
});
}
// Handle addParticipant action
if (action === "addParticipant") {
const resolvedChatGuid = await resolveChatGuid();
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
const address =
readStringParam(params, "address") ??
readStringParam(params, "participant");
if (!address) {
throw new Error("BlueBubbles addParticipant requires address or participant parameter.");
throw new Error(
"BlueBubbles addParticipant requires address or participant parameter.",
);
}
await addBlueBubblesParticipant(resolvedChatGuid, address, opts);
return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid });
return jsonResult({
ok: true,
added: address,
chatGuid: resolvedChatGuid,
});
}
// Handle removeParticipant action
if (action === "removeParticipant") {
const resolvedChatGuid = await resolveChatGuid();
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
const address =
readStringParam(params, "address") ??
readStringParam(params, "participant");
if (!address) {
throw new Error("BlueBubbles removeParticipant requires address or participant parameter.");
throw new Error(
"BlueBubbles removeParticipant requires address or participant parameter.",
);
}
await removeBlueBubblesParticipant(resolvedChatGuid, address, opts);
return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid });
return jsonResult({
ok: true,
removed: address,
chatGuid: resolvedChatGuid,
});
}
// Handle leaveGroup action
@@ -400,12 +475,14 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
const filename = readStringParam(params, "filename", { required: true });
const caption = readStringParam(params, "caption");
const contentType =
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
readStringParam(params, "contentType") ??
readStringParam(params, "mimeType");
const asVoice = readBooleanParam(params, "asVoice");
// Buffer can come from params.buffer (base64) or params.path (file path)
const base64Buffer = readStringParam(params, "buffer");
const filePath = readStringParam(params, "path") ?? readStringParam(params, "filePath");
const filePath =
readStringParam(params, "path") ?? readStringParam(params, "filePath");
let buffer: Uint8Array;
if (base64Buffer) {
@@ -417,7 +494,9 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
"BlueBubbles sendAttachment: filePath not supported in action, provide buffer as base64.",
);
} else {
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
throw new Error(
"BlueBubbles sendAttachment requires buffer (base64) parameter.",
);
}
const result = await sendBlueBubblesAttachment({
@@ -433,6 +512,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
return jsonResult({ ok: true, messageId: result.messageId });
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
throw new Error(
`Action ${action} is not supported for provider ${providerId}.`,
);
},
};

View File

@@ -1,6 +1,9 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import type { BlueBubblesAttachment } from "./types.js";
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
import {
downloadBlueBubblesAttachment,
sendBlueBubblesAttachment,
} from "./attachments.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
@@ -110,7 +113,9 @@ describe("downloadBlueBubblesAttachment", () => {
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" };
const attachment: BlueBubblesAttachment = {
guid: "att/with/special chars",
};
await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",

View File

@@ -3,7 +3,10 @@ import crypto from "node:crypto";
import path from "node:path";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveChatGuidForTarget } from "./send.js";
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
import {
parseBlueBubblesTarget,
normalizeBlueBubblesHandle,
} from "./targets.js";
import {
blueBubblesFetchWithTimeout,
buildBlueBubblesApiUrl,
@@ -29,7 +32,11 @@ function sanitizeFilename(input: string | undefined, fallback: string): string {
return base || fallback;
}
function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
function ensureExtension(
filename: string,
extension: string,
fallbackBase: string,
): string {
const currentExt = path.extname(filename);
if (currentExt.toLowerCase() === extension) {
return filename;
@@ -42,10 +49,13 @@ function resolveVoiceInfo(filename: string, contentType?: string) {
const normalizedType = contentType?.trim().toLowerCase();
const extension = path.extname(filename).toLowerCase();
const isMp3 =
extension === ".mp3" || (normalizedType ? AUDIO_MIME_MP3.has(normalizedType) : false);
extension === ".mp3" ||
(normalizedType ? AUDIO_MIME_MP3.has(normalizedType) : false);
const isCaf =
extension === ".caf" || (normalizedType ? AUDIO_MIME_CAF.has(normalizedType) : false);
const isAudio = isMp3 || isCaf || Boolean(normalizedType?.startsWith("audio/"));
extension === ".caf" ||
(normalizedType ? AUDIO_MIME_CAF.has(normalizedType) : false);
const isAudio =
isMp3 || isCaf || Boolean(normalizedType?.startsWith("audio/"));
return { isAudio, isMp3, isCaf };
}
@@ -79,7 +89,11 @@ export async function downloadBlueBubblesAttachment(
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
password,
});
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs);
const res = await blueBubblesFetchWithTimeout(
url,
{ method: "GET" },
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(
@@ -88,11 +102,19 @@ export async function downloadBlueBubblesAttachment(
}
const contentType = res.headers.get("content-type") ?? undefined;
const buf = new Uint8Array(await res.arrayBuffer());
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
const maxBytes =
typeof opts.maxBytes === "number"
? opts.maxBytes
: DEFAULT_ATTACHMENT_MAX_BYTES;
if (buf.byteLength > maxBytes) {
throw new Error(`BlueBubbles attachment too large (${buf.byteLength} bytes)`);
throw new Error(
`BlueBubbles attachment too large (${buf.byteLength} bytes)`,
);
}
return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
return {
buffer: buf,
contentType: contentType ?? attachment.mimeType ?? undefined,
};
}
export type SendBlueBubblesAttachmentResult = {
@@ -161,7 +183,14 @@ export async function sendBlueBubblesAttachment(params: {
asVoice?: boolean;
opts?: BlueBubblesAttachmentOpts;
}): Promise<SendBlueBubblesAttachmentResult> {
const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params;
const {
to,
caption,
replyToMessageGuid,
replyToPartIndex,
asVoice,
opts = {},
} = params;
let { buffer, filename, contentType } = params;
const wantsVoice = asVoice === true;
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
@@ -174,7 +203,9 @@ export async function sendBlueBubblesAttachment(params: {
if (isAudioMessage) {
const voiceInfo = resolveVoiceInfo(filename, contentType);
if (!voiceInfo.isAudio) {
throw new Error("BlueBubbles voice messages require audio media (mp3 or caf).");
throw new Error(
"BlueBubbles voice messages require audio media (mp3 or caf).",
);
}
if (voiceInfo.isMp3) {
filename = ensureExtension(filename, ".mp3", fallbackName);
@@ -216,17 +247,30 @@ export async function sendBlueBubblesAttachment(params: {
// Helper to add a form field
const addField = (name: string, value: string) => {
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(encoder.encode(`Content-Disposition: form-data; name="${name}"\r\n\r\n`));
parts.push(
encoder.encode(`Content-Disposition: form-data; name="${name}"\r\n\r\n`),
);
parts.push(encoder.encode(`${value}\r\n`));
};
// Helper to add a file field
const addFile = (name: string, fileBuffer: Uint8Array, fileName: string, mimeType?: string) => {
const addFile = (
name: string,
fileBuffer: Uint8Array,
fileName: string,
mimeType?: string,
) => {
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(
encoder.encode(`Content-Disposition: form-data; name="${name}"; filename="${fileName}"\r\n`),
encoder.encode(
`Content-Disposition: form-data; name="${name}"; filename="${fileName}"\r\n`,
),
);
parts.push(
encoder.encode(
`Content-Type: ${mimeType ?? "application/octet-stream"}\r\n\r\n`,
),
);
parts.push(encoder.encode(`Content-Type: ${mimeType ?? "application/octet-stream"}\r\n\r\n`));
parts.push(fileBuffer);
parts.push(encoder.encode("\r\n"));
};
@@ -246,7 +290,10 @@ export async function sendBlueBubblesAttachment(params: {
const trimmedReplyTo = replyToMessageGuid?.trim();
if (trimmedReplyTo) {
addField("selectedMessageGuid", trimmedReplyTo);
addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0");
addField(
"partIndex",
typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0",
);
}
// Add optional caption

View File

@@ -1,4 +1,8 @@
import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
import type {
ChannelAccountSnapshot,
ChannelPlugin,
OpenClawConfig,
} from "openclaw/plugin-sdk";
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
@@ -23,7 +27,10 @@ import { bluebubblesMessageActions } from "./actions.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import { sendBlueBubblesMedia } from "./media-send.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
import {
monitorBlueBubblesProvider,
resolveWebhookPathFromConfig,
} from "./monitor.js";
import { blueBubblesOnboardingAdapter } from "./onboarding.js";
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js";
@@ -78,7 +85,8 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
onboarding: blueBubblesOnboardingAdapter,
config: {
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }),
resolveAccount: (cfg, accountId) =>
resolveBlueBubblesAccount({ cfg: cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
@@ -104,9 +112,10 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
baseUrl: account.baseUrl,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
String(entry),
),
(
resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom ??
[]
).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
@@ -117,8 +126,11 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
actions: bluebubblesMessageActions,
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId]);
const resolvedAccountId =
accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.bluebubbles.accounts.${resolvedAccountId}.`
: "channels.bluebubbles.";
@@ -128,7 +140,8 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("bluebubbles"),
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
normalizeEntry: (raw) =>
normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
};
},
collectWarnings: ({ account }) => {
@@ -152,11 +165,15 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
if (looksLikeBlueBubblesTargetId(value)) {
return true;
}
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(
value,
);
};
// Helper to extract a clean handle from any BlueBubbles target format
const extractCleanDisplay = (value: string | undefined): string | null => {
const extractCleanDisplay = (
value: string | undefined,
): string | null => {
const trimmed = value?.trim();
if (!trimmed) {
return null;
@@ -278,7 +295,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
enabled: true,
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
...(input.password ? { password: input.password } : {}),
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
...(input.webhookPath
? { webhookPath: input.webhookPath }
: {}),
},
},
},
@@ -288,7 +307,8 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
},
pairing: {
idLabel: "bluebubblesSenderId",
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
normalizeAllowEntry: (entry) =>
normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
notifyApproval: async ({ cfg, id }) => {
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
cfg: cfg,
@@ -303,16 +323,21 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
if (!trimmed) {
return {
ok: false,
error: new Error("Delivering to BlueBubbles requires --to <handle|chat_guid:GUID>"),
error: new Error(
"Delivering to BlueBubbles requires --to <handle|chat_guid:GUID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
const rawReplyToId =
typeof replyToId === "string" ? replyToId.trim() : "";
// Resolve short ID (e.g., "5") to full UUID
const replyToMessageGuid = rawReplyToId
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
? resolveBlueBubblesMessageId(rawReplyToId, {
requireKnownShortId: true,
})
: "";
const result = await sendMessageBlueBubbles(to, text, {
cfg: cfg,
@@ -323,13 +348,14 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
},
sendMedia: async (ctx) => {
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
};
const { mediaPath, mediaBuffer, contentType, filename, caption } =
ctx as {
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
};
const resolvedCaption = caption ?? text;
const result = await sendBlueBubblesMedia({
cfg: cfg,
@@ -400,13 +426,16 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
accountId: account.accountId,
baseUrl: account.baseUrl,
});
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
ctx.log?.info(
`[${account.accountId}] starting provider (webhook=${webhookPath})`,
);
return monitorBlueBubblesProvider({
account,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
statusSink: (patch) =>
ctx.setStatus({ accountId: ctx.accountId, ...patch }),
webhookPath,
});
},

View File

@@ -1,5 +1,9 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
import {
markBlueBubblesChatRead,
sendBlueBubblesTyping,
setGroupIconBlueBubbles,
} from "./chat.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
@@ -68,7 +72,9 @@ describe("chat", () => {
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/read"),
expect.stringContaining(
"/api/v1/chat/iMessage%3B-%3B%2B15551234567/read",
),
expect.objectContaining({ method: "POST" }),
);
});
@@ -160,9 +166,9 @@ describe("chat", () => {
});
it("throws when serverUrl is missing", async () => {
await expect(sendBlueBubblesTyping("chat-guid", true, {})).rejects.toThrow(
"serverUrl is required",
);
await expect(
sendBlueBubblesTyping("chat-guid", true, {}),
).rejects.toThrow("serverUrl is required");
});
it("throws when password is missing", async () => {
@@ -185,7 +191,9 @@ describe("chat", () => {
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"),
expect.stringContaining(
"/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing",
),
expect.objectContaining({ method: "POST" }),
);
});
@@ -202,7 +210,9 @@ describe("chat", () => {
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"),
expect.stringContaining(
"/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing",
),
expect.objectContaining({ method: "DELETE" }),
);
});
@@ -336,15 +346,25 @@ describe("chat", () => {
it("throws when serverUrl is missing", async () => {
await expect(
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {}),
setGroupIconBlueBubbles(
"chat-guid",
new Uint8Array([1, 2, 3]),
"icon.png",
{},
),
).rejects.toThrow("serverUrl is required");
});
it("throws when password is missing", async () => {
await expect(
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
}),
setGroupIconBlueBubbles(
"chat-guid",
new Uint8Array([1, 2, 3]),
"icon.png",
{
serverUrl: "http://localhost:1234",
},
),
).rejects.toThrow("password is required");
});
@@ -355,11 +375,16 @@ describe("chat", () => {
});
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
await setGroupIconBlueBubbles("iMessage;-;chat-guid", buffer, "icon.png", {
serverUrl: "http://localhost:1234",
password: "test-password",
contentType: "image/png",
});
await setGroupIconBlueBubbles(
"iMessage;-;chat-guid",
buffer,
"icon.png",
{
serverUrl: "http://localhost:1234",
password: "test-password",
contentType: "image/png",
},
);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/iMessage%3B-%3Bchat-guid/icon"),
@@ -378,10 +403,15 @@ describe("chat", () => {
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "my-secret",
});
await setGroupIconBlueBubbles(
"chat-123",
new Uint8Array([1, 2, 3]),
"icon.png",
{
serverUrl: "http://localhost:1234",
password: "my-secret",
},
);
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=my-secret");
@@ -395,10 +425,15 @@ describe("chat", () => {
});
await expect(
setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "test",
}),
setGroupIconBlueBubbles(
"chat-123",
new Uint8Array([1, 2, 3]),
"icon.png",
{
serverUrl: "http://localhost:1234",
password: "test",
},
),
).rejects.toThrow("setGroupIcon failed (500): Internal error");
});
@@ -408,10 +443,15 @@ describe("chat", () => {
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles(" chat-with-spaces ", new Uint8Array([1]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "test",
});
await setGroupIconBlueBubbles(
" chat-with-spaces ",
new Uint8Array([1]),
"icon.png",
{
serverUrl: "http://localhost:1234",
password: "test",
},
);
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/icon");
@@ -424,16 +464,21 @@ describe("chat", () => {
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://config-server:9999",
password: "config-pass",
await setGroupIconBlueBubbles(
"chat-123",
new Uint8Array([1]),
"icon.png",
{
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://config-server:9999",
password: "config-pass",
},
},
},
},
});
);
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("config-server:9999");
@@ -446,11 +491,16 @@ describe("chat", () => {
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "custom-icon.jpg", {
serverUrl: "http://localhost:1234",
password: "test",
contentType: "image/jpeg",
});
await setGroupIconBlueBubbles(
"chat-123",
new Uint8Array([1, 2, 3]),
"custom-icon.jpg",
{
serverUrl: "http://localhost:1234",
password: "test",
contentType: "image/jpeg",
},
);
const body = mockFetch.mock.calls[0][1].body as Uint8Array;
const bodyString = new TextDecoder().decode(body);

View File

@@ -1,7 +1,10 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import crypto from "node:crypto";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
import {
blueBubblesFetchWithTimeout,
buildBlueBubblesApiUrl,
} from "./types.js";
export type BlueBubblesChatOpts = {
serverUrl?: string;
@@ -41,10 +44,16 @@ export async function markBlueBubblesChatRead(
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
password,
});
const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
const res = await blueBubblesFetchWithTimeout(
url,
{ method: "POST" },
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`);
throw new Error(
`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`,
);
}
}
@@ -70,7 +79,9 @@ export async function sendBlueBubblesTyping(
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
throw new Error(
`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`,
);
}
}
@@ -81,7 +92,10 @@ export async function sendBlueBubblesTyping(
export async function editBlueBubblesMessage(
messageGuid: string,
newText: string,
opts: BlueBubblesChatOpts & { partIndex?: number; backwardsCompatMessage?: string } = {},
opts: BlueBubblesChatOpts & {
partIndex?: number;
backwardsCompatMessage?: string;
} = {},
): Promise<void> {
const trimmedGuid = messageGuid.trim();
if (!trimmedGuid) {
@@ -101,7 +115,8 @@ export async function editBlueBubblesMessage(
const payload = {
editedMessage: trimmedText,
backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`,
backwardsCompatibilityMessage:
opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`,
partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
};
@@ -117,7 +132,9 @@ export async function editBlueBubblesMessage(
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`);
throw new Error(
`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`,
);
}
}
@@ -157,7 +174,9 @@ export async function unsendBlueBubblesMessage(
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`);
throw new Error(
`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`,
);
}
}
@@ -193,7 +212,9 @@ export async function renameBlueBubblesChat(
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`);
throw new Error(
`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`,
);
}
}
@@ -233,7 +254,9 @@ export async function addBlueBubblesParticipant(
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`);
throw new Error(
`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`,
);
}
}
@@ -298,11 +321,17 @@ export async function leaveBlueBubblesChat(
password,
});
const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
const res = await blueBubblesFetchWithTimeout(
url,
{ method: "POST" },
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`);
throw new Error(
`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`,
);
}
}
@@ -339,10 +368,14 @@ export async function setGroupIconBlueBubbles(
// Add file field named "icon" as per API spec
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(
encoder.encode(`Content-Disposition: form-data; name="icon"; filename="${filename}"\r\n`),
encoder.encode(
`Content-Disposition: form-data; name="icon"; filename="${filename}"\r\n`,
),
);
parts.push(
encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`),
encoder.encode(
`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`,
),
);
parts.push(buffer);
parts.push(encoder.encode("\r\n"));
@@ -373,6 +406,8 @@ export async function setGroupIconBlueBubbles(
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`);
throw new Error(
`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`,
);
}
}

View File

@@ -1,6 +1,9 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk";
import {
resolveChannelMediaMaxBytes,
type OpenClawConfig,
} from "openclaw/plugin-sdk";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { getBlueBubblesRuntime } from "./runtime.js";
@@ -99,7 +102,9 @@ export async function sendBlueBubblesMedia(params: {
if (!resolvedContentType) {
const hint = mediaPath ?? mediaUrl;
const detected = await core.media.detectMime({
buffer: Buffer.isBuffer(mediaBuffer) ? mediaBuffer : Buffer.from(mediaBuffer),
buffer: Buffer.isBuffer(mediaBuffer)
? mediaBuffer
: Buffer.from(mediaBuffer),
filePath: hint,
});
resolvedContentType = detected ?? undefined;
@@ -110,15 +115,19 @@ export async function sendBlueBubblesMedia(params: {
} else {
const source = mediaPath ?? mediaUrl;
if (!source) {
throw new Error("BlueBubbles media delivery requires mediaUrl, mediaPath, or mediaBuffer.");
throw new Error(
"BlueBubbles media delivery requires mediaUrl, mediaPath, or mediaBuffer.",
);
}
if (HTTP_URL_RE.test(source)) {
const fetched = await core.channel.media.fetchRemoteMedia({
url: source,
maxBytes: typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined,
maxBytes:
typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined,
});
buffer = fetched.buffer;
resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined;
resolvedContentType =
resolvedContentType ?? fetched.contentType ?? undefined;
resolvedFilename = resolvedFilename ?? fetched.fileName;
} else {
const localPath = resolveLocalMediaPath(source);
@@ -145,7 +154,9 @@ export async function sendBlueBubblesMedia(params: {
// Resolve short ID (e.g., "5") to full UUID
const replyToMessageGuid = replyToId?.trim()
? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true })
? resolveBlueBubblesMessageId(replyToId.trim(), {
requireKnownShortId: true,
})
: undefined;
const attachmentResult = await sendBlueBubblesAttachment({

View File

@@ -1,7 +1,10 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { EventEmitter } from "node:events";
import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
import {
removeAckReactionAfterReply,
shouldAckReaction,
} from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import {
@@ -14,7 +17,9 @@ import { setBlueBubblesRuntime } from "./runtime.js";
// Mock dependencies
vi.mock("./send.js", () => ({
resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
resolveChatGuidForTarget: vi
.fn()
.mockResolvedValue("iMessage;-;+15551234567"),
sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }),
}));
@@ -31,7 +36,8 @@ vi.mock("./attachments.js", () => ({
}));
vi.mock("./reactions.js", async () => {
const actual = await vi.importActual<typeof import("./reactions.js")>("./reactions.js");
const actual =
await vi.importActual<typeof import("./reactions.js")>("./reactions.js");
return {
...actual,
sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
@@ -42,7 +48,9 @@ vi.mock("./reactions.js", async () => {
const mockEnqueueSystemEvent = vi.fn();
const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE");
const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true });
const mockUpsertPairingRequest = vi
.fn()
.mockResolvedValue({ code: "TESTCODE", created: true });
const mockResolveAgentRoute = vi.fn(() => ({
agentId: "main",
accountId: "default",
@@ -54,7 +62,9 @@ const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
);
const mockResolveRequireMention = vi.fn(() => false);
const mockResolveGroupPolicy = vi.fn(() => "open");
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(async () => undefined);
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
async () => undefined,
);
const mockHasControlCommand = vi.fn(() => false);
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
@@ -73,34 +83,45 @@ function createMockRuntime(): PluginRuntime {
return {
version: "1.0.0",
config: {
loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"],
writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"],
loadConfig: vi.fn(
() => ({}),
) as unknown as PluginRuntime["config"]["loadConfig"],
writeConfigFile:
vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"],
},
system: {
enqueueSystemEvent:
mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
runCommandWithTimeout:
vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
},
media: {
loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
loadWebMedia:
vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
detectMime: vi.fn() as unknown as PluginRuntime["media"]["detectMime"],
mediaKindFromMime: vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"],
mediaKindFromMime:
vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"],
isVoiceCompatibleAudio:
vi.fn() as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"],
getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
getImageMetadata:
vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
resizeToJpeg:
vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
},
tools: {
createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
createMemoryGetTool:
vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
createMemorySearchTool:
vi.fn() as unknown as PluginRuntime["tools"]["createMemorySearchTool"],
registerMemoryCli: vi.fn() as unknown as PluginRuntime["tools"]["registerMemoryCli"],
registerMemoryCli:
vi.fn() as unknown as PluginRuntime["tools"]["registerMemoryCli"],
},
channel: {
text: {
chunkMarkdownText:
mockChunkMarkdownText as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownText"],
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
chunkText:
vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
resolveTextChunkLimit: vi.fn(
() => 4000,
) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
@@ -258,7 +279,9 @@ function createMockRequest(
req.method = method;
req.url = url;
req.headers = headers;
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" };
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
// Emit body data after a microtask
// oxlint-disable-next-line no-floating-promises
@@ -271,7 +294,10 @@ function createMockRequest(
return req;
}
function createMockResponse(): ServerResponse & { body: string; statusCode: number } {
function createMockResponse(): ServerResponse & {
body: string;
statusCode: number;
} {
const res = {
statusCode: 200,
body: "",
@@ -297,7 +323,10 @@ describe("BlueBubbles webhook monitor", () => {
// Reset short ID state between tests for predictable behavior
_resetBlueBubblesShortIdState();
mockReadAllowFromStore.mockResolvedValue([]);
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
mockUpsertPairingRequest.mockResolvedValue({
code: "TESTCODE",
created: true,
});
mockResolveRequireMention.mockReturnValue(false);
mockHasControlCommand.mockReturnValue(false);
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
@@ -384,7 +413,11 @@ describe("BlueBubbles webhook monitor", () => {
path: "/bluebubbles-webhook",
});
const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
const req = createMockRequest(
"POST",
"/bluebubbles-webhook",
"invalid json {{",
);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
@@ -400,16 +433,20 @@ describe("BlueBubbles webhook monitor", () => {
setBlueBubblesRuntime(core);
// Mock non-localhost request
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
const req = createMockRequest(
"POST",
"/bluebubbles-webhook?password=secret-token",
{
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
},
});
);
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
@@ -475,16 +512,20 @@ describe("BlueBubbles webhook monitor", () => {
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
const req = createMockRequest(
"POST",
"/bluebubbles-webhook?password=wrong-token",
{
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
},
});
);
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
@@ -593,13 +634,19 @@ describe("BlueBubbles webhook monitor", () => {
});
it("extracts chatGuid from nested chat object fields (webhook variant)", async () => {
const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js");
const { sendMessageBlueBubbles, resolveChatGuidForTarget } =
await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockClear();
vi.mocked(resolveChatGuidForTarget).mockClear();
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
});
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async (params) => {
await params.dispatcherOptions.deliver(
{ text: "replying now" },
{ kind: "final" },
);
},
);
const account = createMockAccount({ groupPolicy: "open" });
const config: OpenClawConfig = {};
@@ -720,7 +767,9 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(
mockDispatchReplyWithBufferedBlockDispatcher,
).not.toHaveBeenCalled();
});
it("triggers pairing flow for unknown sender when dmPolicy=pairing", async () => {
@@ -761,11 +810,16 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockUpsertPairingRequest).toHaveBeenCalled();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(
mockDispatchReplyWithBufferedBlockDispatcher,
).not.toHaveBeenCalled();
});
it("does not resend pairing reply when request already exists", async () => {
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: false });
mockUpsertPairingRequest.mockResolvedValue({
code: "TESTCODE",
created: false,
});
// Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty
// allowlist that doesn't include the sender
@@ -881,7 +935,9 @@ describe("BlueBubbles webhook monitor", () => {
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(
mockDispatchReplyWithBufferedBlockDispatcher,
).not.toHaveBeenCalled();
});
});
@@ -959,7 +1015,9 @@ describe("BlueBubbles webhook monitor", () => {
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(
mockDispatchReplyWithBufferedBlockDispatcher,
).not.toHaveBeenCalled();
});
it("treats chat_guid groups as group even when isGroup=false", async () => {
@@ -998,7 +1056,9 @@ describe("BlueBubbles webhook monitor", () => {
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(
mockDispatchReplyWithBufferedBlockDispatcher,
).not.toHaveBeenCalled();
});
it("allows group messages from allowed chat_guid in groupAllowFrom", async () => {
@@ -1079,7 +1139,8 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
const callArgs =
mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.WasMentioned).toBe(true);
});
@@ -1119,7 +1180,9 @@ describe("BlueBubbles webhook monitor", () => {
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(
mockDispatchReplyWithBufferedBlockDispatcher,
).not.toHaveBeenCalled();
});
it("processes group message without mention when requireMention=false", async () => {
@@ -1201,9 +1264,12 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
const callArgs =
mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.GroupSubject).toBe("Family");
expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)");
expect(callArgs.ctx.GroupMembers).toBe(
"Alice (+15551234567), Bob (+15557654321)",
);
});
});
@@ -1331,13 +1397,18 @@ describe("BlueBubbles webhook monitor", () => {
);
// Not flushed yet; still within the debounce window.
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(
mockDispatchReplyWithBufferedBlockDispatcher,
).not.toHaveBeenCalled();
// After the debounce window, the combined message should be processed exactly once.
await vi.advanceTimersByTimeAsync(600);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(
mockDispatchReplyWithBufferedBlockDispatcher,
).toHaveBeenCalledTimes(1);
const callArgs =
mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.MediaPaths).toEqual(["/tmp/test-media.jpg"]);
expect(callArgs.ctx.Body).toContain("hello");
} finally {
@@ -1386,7 +1457,8 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
const callArgs =
mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
// ReplyToId is the full UUID since it wasn't previously cached
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message");
@@ -1434,14 +1506,18 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
const callArgs =
mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.ReplyToId).toBe("p:1/msg-0");
expect(callArgs.ctx.ReplyToIdFull).toBe("p:1/msg-0");
expect(callArgs.ctx.Body).toContain("[[reply_to:p:1/msg-0]]");
});
it("hydrates missing reply sender/body from the recent-message cache", async () => {
const account = createMockAccount({ dmPolicy: "open", groupPolicy: "open" });
const account = createMockAccount({
dmPolicy: "open",
groupPolicy: "open",
});
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
@@ -1469,7 +1545,11 @@ describe("BlueBubbles webhook monitor", () => {
},
};
const originalReq = createMockRequest("POST", "/bluebubbles-webhook", originalPayload);
const originalReq = createMockRequest(
"POST",
"/bluebubbles-webhook",
originalPayload,
);
const originalRes = createMockResponse();
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
@@ -1493,14 +1573,19 @@ describe("BlueBubbles webhook monitor", () => {
},
};
const replyReq = createMockRequest("POST", "/bluebubbles-webhook", replyPayload);
const replyReq = createMockRequest(
"POST",
"/bluebubbles-webhook",
replyPayload,
);
const replyRes = createMockResponse();
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
const callArgs =
mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
// ReplyToId uses short ID "1" (first cached message) for token savings
expect(callArgs.ctx.ReplyToId).toBe("1");
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
@@ -1545,7 +1630,8 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
const callArgs =
mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
});
});
@@ -1585,7 +1671,8 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
const callArgs =
mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.RawBody).toBe("Loved this idea");
expect(callArgs.ctx.Body).toContain("Loved this idea");
expect(callArgs.ctx.Body).not.toContain("reacted with");
@@ -1625,7 +1712,8 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
const callArgs =
mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.RawBody).toBe("reacted with 😅");
expect(callArgs.ctx.Body).toContain("reacted with 😅");
expect(callArgs.ctx.Body).not.toContain("[[reply_to:");
@@ -1770,7 +1858,9 @@ describe("BlueBubbles webhook monitor", () => {
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(
mockDispatchReplyWithBufferedBlockDispatcher,
).not.toHaveBeenCalled();
});
});
@@ -1887,9 +1977,11 @@ describe("BlueBubbles webhook monitor", () => {
},
};
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.onReplyStart?.();
});
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async (params) => {
await params.dispatcherOptions.onReplyStart?.();
},
);
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
@@ -1935,11 +2027,16 @@ describe("BlueBubbles webhook monitor", () => {
},
};
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.onReplyStart?.();
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
await params.dispatcherOptions.onIdle?.();
});
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async (params) => {
await params.dispatcherOptions.onReplyStart?.();
await params.dispatcherOptions.deliver(
{ text: "replying now" },
{ kind: "final" },
);
await params.dispatcherOptions.onIdle?.();
},
);
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
@@ -1984,7 +2081,9 @@ describe("BlueBubbles webhook monitor", () => {
},
};
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async () => undefined);
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async () => undefined,
);
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
@@ -2004,9 +2103,14 @@ describe("BlueBubbles webhook monitor", () => {
it("enqueues system event for outbound message id", async () => {
mockEnqueueSystemEvent.mockClear();
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
});
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async (params) => {
await params.dispatcherOptions.deliver(
{ text: "replying now" },
{ kind: "final" },
);
},
);
const account = createMockAccount();
const config: OpenClawConfig = {};
@@ -2245,7 +2349,8 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
const callArgs =
mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
// MessageSid should be short ID "1" instead of full UUID
expect(callArgs.ctx.MessageSid).toBe("1");
expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345");
@@ -2289,7 +2394,9 @@ describe("BlueBubbles webhook monitor", () => {
});
it("returns UUID unchanged when not in cache", () => {
expect(resolveBlueBubblesMessageId("msg-not-cached")).toBe("msg-not-cached");
expect(resolveBlueBubblesMessageId("msg-not-cached")).toBe(
"msg-not-cached",
);
});
it("returns short ID unchanged when numeric but not in cache", () => {
@@ -2297,9 +2404,9 @@ describe("BlueBubbles webhook monitor", () => {
});
it("throws when numeric short ID is missing and requireKnownShortId is set", () => {
expect(() => resolveBlueBubblesMessageId("999", { requireKnownShortId: true })).toThrow(
/short message id/i,
);
expect(() =>
resolveBlueBubblesMessageId("999", { requireKnownShortId: true }),
).toThrow(/short message id/i);
});
});
@@ -2336,7 +2443,9 @@ describe("BlueBubbles webhook monitor", () => {
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(
mockDispatchReplyWithBufferedBlockDispatcher,
).not.toHaveBeenCalled();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -22,9 +22,14 @@ import { normalizeBlueBubblesServerUrl } from "./types.js";
const channel = "bluebubbles" as const;
function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
function setBlueBubblesDmPolicy(
cfg: OpenClawConfig,
dmPolicy: DmPolicy,
): OpenClawConfig {
const allowFrom =
dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.bluebubbles?.allowFrom) : undefined;
dmPolicy === "open"
? addWildcardAllowFrom(cfg.channels?.bluebubbles?.allowFrom)
: undefined;
return {
...cfg,
channels: {
@@ -151,12 +156,19 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
return {
channel,
configured,
statusLines: [`BlueBubbles: ${configured ? "configured" : "needs setup"}`],
statusLines: [
`BlueBubbles: ${configured ? "configured" : "needs setup"}`,
],
selectionHint: configured ? "configured" : "iMessage via BlueBubbles app",
quickstartScore: configured ? 1 : 0,
};
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
configure: async ({
cfg,
prompter,
accountOverrides,
shouldPromptAccountIds,
}) => {
const blueBubblesOverride = accountOverrides.bluebubbles?.trim();
const defaultAccountId = resolveDefaultBlueBubblesAccountId(cfg);
let accountId = blueBubblesOverride
@@ -245,7 +257,8 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
);
const entered = await prompter.text({
message: "BlueBubbles password",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
validate: (value) =>
String(value ?? "").trim() ? undefined : "Required",
});
password = String(entered).trim();
} else {
@@ -256,7 +269,8 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
if (!keepPassword) {
const entered = await prompter.text({
message: "BlueBubbles password",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
validate: (value) =>
String(value ?? "").trim() ? undefined : "Required",
});
password = String(entered).trim();
}
@@ -265,8 +279,11 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
// Prompt for webhook path (optional)
const existingWebhookPath = resolvedAccount.config.webhookPath?.trim();
const wantsWebhook = await prompter.confirm({
message: "Configure a custom webhook path? (default: /bluebubbles-webhook)",
initialValue: Boolean(existingWebhookPath && existingWebhookPath !== "/bluebubbles-webhook"),
message:
"Configure a custom webhook path? (default: /bluebubbles-webhook)",
initialValue: Boolean(
existingWebhookPath && existingWebhookPath !== "/bluebubbles-webhook",
),
});
let webhookPath = "/bluebubbles-webhook";
if (wantsWebhook) {
@@ -315,7 +332,9 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
...next.channels?.bluebubbles?.accounts,
[accountId]: {
...next.channels?.bluebubbles?.accounts?.[accountId],
enabled: next.channels?.bluebubbles?.accounts?.[accountId]?.enabled ?? true,
enabled:
next.channels?.bluebubbles?.accounts?.[accountId]?.enabled ??
true,
serverUrl,
password,
webhookPath,

View File

@@ -1,4 +1,7 @@
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
import {
buildBlueBubblesApiUrl,
blueBubblesFetchWithTimeout,
} from "./types.js";
export type BlueBubblesProbe = {
ok: boolean;
@@ -17,7 +20,10 @@ export type BlueBubblesServerInfo = {
};
/** Cache server info by account ID to avoid repeated API calls */
const serverInfoCache = new Map<string, { info: BlueBubblesServerInfo; expires: number }>();
const serverInfoCache = new Map<
string,
{ info: BlueBubblesServerInfo; expires: number }
>();
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
function buildCacheKey(accountId?: string): string {
@@ -46,16 +52,30 @@ export async function fetchBlueBubblesServerInfo(params: {
return cached.info;
}
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password });
const url = buildBlueBubblesApiUrl({
baseUrl,
path: "/api/v1/server/info",
password,
});
try {
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs ?? 5000);
const res = await blueBubblesFetchWithTimeout(
url,
{ method: "GET" },
params.timeoutMs ?? 5000,
);
if (!res.ok) {
return null;
}
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
const payload = (await res.json().catch(() => null)) as Record<
string,
unknown
> | null;
const data = payload?.data as BlueBubblesServerInfo | undefined;
if (data) {
serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS });
serverInfoCache.set(cacheKey, {
info: data,
expires: Date.now() + CACHE_TTL_MS,
});
}
return data ?? null;
} catch {
@@ -67,7 +87,9 @@ export async function fetchBlueBubblesServerInfo(params: {
* Get cached server info synchronously (for use in listActions).
* Returns null if not cached or expired.
*/
export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null {
export function getCachedBlueBubblesServerInfo(
accountId?: string,
): BlueBubblesServerInfo | null {
const cacheKey = buildCacheKey(accountId);
const cached = serverInfoCache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
@@ -118,9 +140,17 @@ export async function probeBlueBubbles(params: {
if (!password) {
return { ok: false, error: "password not configured" };
}
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password });
const url = buildBlueBubblesApiUrl({
baseUrl,
path: "/api/v1/ping",
password,
});
try {
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs);
const res = await blueBubblesFetchWithTimeout(
url,
{ method: "GET" },
params.timeoutMs,
);
if (!res.ok) {
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
}

View File

@@ -1,6 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
import {
blueBubblesFetchWithTimeout,
buildBlueBubblesApiUrl,
} from "./types.js";
export type BlueBubblesReactionOpts = {
serverUrl?: string;
@@ -10,7 +13,14 @@ export type BlueBubblesReactionOpts = {
cfg?: OpenClawConfig;
};
const REACTION_TYPES = new Set(["love", "like", "dislike", "laugh", "emphasize", "question"]);
const REACTION_TYPES = new Set([
"love",
"like",
"dislike",
"laugh",
"emphasize",
"question",
]);
const REACTION_ALIASES = new Map<string, string>([
// General
@@ -126,7 +136,10 @@ function resolveAccount(params: BlueBubblesReactionOpts) {
return { baseUrl, password };
}
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
export function normalizeBlueBubblesReactionInput(
emoji: string,
remove?: boolean,
): string {
const trimmed = emoji.trim();
if (!trimmed) {
throw new Error("BlueBubbles reaction requires an emoji or name.");
@@ -136,7 +149,8 @@ export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolea
raw = raw.slice(1);
}
const aliased = REACTION_ALIASES.get(raw) ?? raw;
const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased;
const mapped =
REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased;
if (!REACTION_TYPES.has(mapped)) {
throw new Error(`Unsupported BlueBubbles reaction: ${trimmed}`);
}
@@ -159,7 +173,10 @@ export async function sendBlueBubblesReaction(params: {
if (!messageGuid) {
throw new Error("BlueBubbles reaction requires messageGuid.");
}
const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
const reaction = normalizeBlueBubblesReactionInput(
params.emoji,
params.remove,
);
const { baseUrl, password } = resolveAccount(params.opts ?? {});
const url = buildBlueBubblesApiUrl({
baseUrl,
@@ -183,6 +200,8 @@ export async function sendBlueBubblesReaction(params: {
);
if (!res.ok) {
const errorText = await res.text();
throw new Error(`BlueBubbles reaction failed (${res.status}): ${errorText || "unknown"}`);
throw new Error(
`BlueBubbles reaction failed (${res.status}): ${errorText || "unknown"}`,
);
}
}

View File

@@ -162,7 +162,10 @@ describe("send", () => {
data: [
{
guid: "iMessage;+;group-123",
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
participants: [
{ address: "+15551234567" },
{ address: "+15550001111" },
],
},
{
guid: "iMessage;-;+15551234567",
@@ -371,9 +374,9 @@ describe("send", () => {
});
it("throws when serverUrl is missing", async () => {
await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow(
"serverUrl is required",
);
await expect(
sendMessageBlueBubbles("+15551234567", "Hello", {}),
).rejects.toThrow("serverUrl is required");
});
it("throws when password is missing", async () => {
@@ -422,10 +425,14 @@ describe("send", () => {
),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
serverUrl: "http://localhost:1234",
password: "test",
});
const result = await sendMessageBlueBubbles(
"+15551234567",
"Hello world!",
{
serverUrl: "http://localhost:1234",
password: "test",
},
);
expect(result.messageId).toBe("msg-uuid-123");
expect(mockFetch).toHaveBeenCalledTimes(2);
@@ -454,10 +461,14 @@ describe("send", () => {
),
});
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
serverUrl: "http://localhost:1234",
password: "test",
});
const result = await sendMessageBlueBubbles(
"+15550009999",
"Hello new chat",
{
serverUrl: "http://localhost:1234",
password: "test",
},
);
expect(result.messageId).toBe("new-msg-guid");
expect(mockFetch).toHaveBeenCalledTimes(2);
@@ -566,7 +577,9 @@ describe("send", () => {
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBe("private-api");
expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink");
expect(body.effectId).toBe(
"com.apple.MobileSMS.expressivesend.invisibleink",
);
});
it("sends message with chat_guid target directly", async () => {
@@ -755,7 +768,8 @@ describe("send", () => {
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })),
text: () =>
Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
@@ -790,7 +804,8 @@ describe("send", () => {
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })),
text: () =>
Promise.resolve(JSON.stringify({ data: { guid: "msg" } })),
});
await sendMessageBlueBubbles("+15551234567", "Hello", {

View File

@@ -218,8 +218,14 @@ async function queryChats(params: {
if (!res.ok) {
return [];
}
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null;
const payload = (await res.json().catch(() => null)) as Record<
string,
unknown
> | null;
const data =
payload && typeof payload.data !== "undefined"
? (payload.data as unknown)
: null;
return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : [];
}
@@ -234,10 +240,15 @@ export async function resolveChatGuidForTarget(params: {
}
const normalizedHandle =
params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : "";
const targetChatId = params.target.kind === "chat_id" ? params.target.chatId : null;
params.target.kind === "handle"
? normalizeBlueBubblesHandle(params.target.address)
: "";
const targetChatId =
params.target.kind === "chat_id" ? params.target.chatId : null;
const targetChatIdentifier =
params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null;
params.target.kind === "chat_identifier"
? params.target.chatIdentifier
: null;
const limit = 500;
let participantMatch: string | null = null;
@@ -299,8 +310,8 @@ export async function resolveChatGuidForTarget(params: {
// This prevents routing "send to +1234567890" to a group chat that contains that number.
const isDmChat = guid.includes(";-;");
if (isDmChat) {
const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
const participants = extractParticipantAddresses(chat).map(
(entry) => normalizeBlueBubblesHandle(entry),
);
if (participants.includes(normalizedHandle)) {
participantMatch = guid;
@@ -354,7 +365,9 @@ async function createNewChatWithMessage(params: {
`BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`,
);
}
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
throw new Error(
`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`,
);
}
const body = await res.text();
if (!body) {
@@ -428,7 +441,8 @@ export async function sendMessageBlueBubbles(
// Add reply threading support
if (opts.replyToMessageGuid) {
payload.selectedMessageGuid = opts.replyToMessageGuid;
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
payload.partIndex =
typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
}
// Add message effects support
@@ -452,7 +466,9 @@ export async function sendMessageBlueBubbles(
);
if (!res.ok) {
const errorText = await res.text();
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
throw new Error(
`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`,
);
}
const body = await res.text();
if (!body) {

View File

@@ -8,62 +8,78 @@ import {
describe("normalizeBlueBubblesMessagingTarget", () => {
it("normalizes chat_guid targets", () => {
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe(
"chat_guid:ABC-123",
);
});
it("normalizes group numeric targets to chat_id", () => {
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe(
"chat_id:123",
);
});
it("strips provider prefix and normalizes handles", () => {
expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
"imessage:user@example.com",
);
expect(
normalizeBlueBubblesMessagingTarget(
"bluebubbles:imessage:User@Example.com",
),
).toBe("imessage:user@example.com");
});
it("extracts handle from DM chat_guid for cross-context matching", () => {
// DM format: service;-;handle
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe(
"+19257864429",
);
expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe(
"+15551234567",
);
expect(
normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429"),
).toBe("+19257864429");
expect(
normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567"),
).toBe("+15551234567");
// Email handles
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe(
"user@example.com",
);
expect(
normalizeBlueBubblesMessagingTarget(
"chat_guid:iMessage;-;user@example.com",
),
).toBe("user@example.com");
});
it("preserves group chat_guid format", () => {
// Group format: service;+;groupId
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe(
"chat_guid:iMessage;+;chat123456789",
);
expect(
normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789"),
).toBe("chat_guid:iMessage;+;chat123456789");
});
it("normalizes raw chat_guid values", () => {
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
"chat_guid:iMessage;+;chat660250192681427962",
expect(
normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962"),
).toBe("chat_guid:iMessage;+;chat660250192681427962");
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe(
"+19257864429",
);
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
});
it("normalizes chat<digits> pattern to chat_identifier format", () => {
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
"chat_identifier:chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123");
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789");
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe(
"chat_identifier:chat123",
);
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe(
"chat_identifier:Chat456789",
);
});
it("normalizes UUID/hex chat identifiers", () => {
expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(
"chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc",
);
expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(
"chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678",
);
expect(
normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc"),
).toBe("chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc");
expect(
normalizeBlueBubblesMessagingTarget(
"1C2D3E4F-1234-5678-9ABC-DEF012345678",
),
).toBe("chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678");
});
});
@@ -81,7 +97,9 @@ describe("looksLikeBlueBubblesTargetId", () => {
});
it("accepts raw chat_guid values", () => {
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
expect(
looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962"),
).toBe(true);
});
it("accepts chat<digits> pattern as chat_id", () => {
@@ -91,8 +109,12 @@ describe("looksLikeBlueBubblesTargetId", () => {
});
it("accepts UUID/hex chat identifiers", () => {
expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true);
expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true);
expect(
looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc"),
).toBe(true);
expect(
looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678"),
).toBe(true);
});
it("rejects display names", () => {
@@ -121,14 +143,19 @@ describe("parseBlueBubblesTarget", () => {
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
expect(
parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678"),
).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({
kind: "chat_id",
chatId: 123,
});
});
it("parses phone numbers as handles", () => {
@@ -140,10 +167,12 @@ describe("parseBlueBubblesTarget", () => {
});
it("parses raw chat_guid format", () => {
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
kind: "chat_guid",
chatGuid: "iMessage;+;chat660250192681427962",
});
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual(
{
kind: "chat_guid",
chatGuid: "iMessage;+;chat660250192681427962",
},
);
});
});
@@ -160,18 +189,25 @@ describe("parseBlueBubblesAllowTarget", () => {
});
it("parses UUID/hex chat identifiers as chat_identifier", () => {
expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
expect(
parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc"),
).toEqual({
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
expect(
parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678"),
).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({
kind: "chat_id",
chatId: 456,
});
});
it("parses phone numbers as handles", () => {

View File

@@ -14,13 +14,19 @@ export type BlueBubblesAllowTarget =
const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"];
const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"];
const CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"];
const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> = [
{ prefix: "imessage:", service: "imessage" },
{ prefix: "sms:", service: "sms" },
{ prefix: "auto:", service: "auto" },
const CHAT_IDENTIFIER_PREFIXES = [
"chat_identifier:",
"chatidentifier:",
"chatident:",
];
const CHAT_IDENTIFIER_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> =
[
{ prefix: "imessage:", service: "imessage" },
{ prefix: "sms:", service: "sms" },
{ prefix: "auto:", service: "auto" },
];
const CHAT_IDENTIFIER_UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const CHAT_IDENTIFIER_HEX_RE = /^[0-9a-f]{24,64}$/i;
function parseRawChatGuid(value: string): string | null {
@@ -67,7 +73,10 @@ function looksLikeRawChatIdentifier(value: string): boolean {
if (/^chat\d+$/i.test(trimmed)) {
return true;
}
return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed);
return (
CHAT_IDENTIFIER_UUID_RE.test(trimmed) ||
CHAT_IDENTIFIER_HEX_RE.test(trimmed)
);
}
export function normalizeBlueBubblesHandle(raw: string): string {
@@ -108,7 +117,9 @@ export function extractHandleFromChatGuid(chatGuid: string): string | null {
return null;
}
export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined {
export function normalizeBlueBubblesMessagingTarget(
raw: string,
): string | undefined {
let trimmed = raw.trim();
if (!trimmed) {
return undefined;
@@ -145,7 +156,10 @@ export function normalizeBlueBubblesMessagingTarget(raw: string): string | undef
}
}
export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean {
export function looksLikeBlueBubblesTargetId(
raw: string,
normalized?: string,
): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
@@ -286,7 +300,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
return { kind: "handle", to: trimmed, service: "auto" };
}
export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget {
export function parseBlueBubblesAllowTarget(
raw: string,
): BlueBubblesAllowTarget {
const trimmed = raw.trim();
if (!trimmed) {
return { kind: "handle", handle: "" };

View File

@@ -95,7 +95,9 @@ export function normalizeBlueBubblesServerUrl(raw: string): string {
if (!trimmed) {
throw new Error("BlueBubbles serverUrl is required");
}
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
const withScheme = /^https?:\/\//i.test(trimmed)
? trimmed
: `http://${trimmed}`;
return withScheme.replace(/\/+$/, "");
}

View File

@@ -92,7 +92,9 @@ const copilotProxyPlugin = {
message: "Model IDs (comma-separated)",
initialValue: DEFAULT_MODEL_IDS.join(", "),
validate: (value) =>
parseModelIds(value).length > 0 ? undefined : "Enter at least one model id",
parseModelIds(value).length > 0
? undefined
: "Enter at least one model id",
});
const baseUrl = normalizeBaseUrl(baseUrlInput);
@@ -119,14 +121,19 @@ const copilotProxyPlugin = {
apiKey: DEFAULT_API_KEY,
api: "openai-completions",
authHeader: false,
models: modelIds.map((modelId) => buildModelDefinition(modelId)),
models: modelIds.map((modelId) =>
buildModelDefinition(modelId),
),
},
},
},
agents: {
defaults: {
models: Object.fromEntries(
modelIds.map((modelId) => [`copilot-proxy/${modelId}`, {}]),
modelIds.map((modelId) => [
`copilot-proxy/${modelId}`,
{},
]),
),
},
},

View File

@@ -96,7 +96,9 @@ vi.mock("@opentelemetry/semantic-conventions", () => ({
}));
vi.mock("openclaw/plugin-sdk", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk")>("openclaw/plugin-sdk");
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk")>(
"openclaw/plugin-sdk",
);
return {
...actual,
registerLogTransport: registerLogTransportMock,
@@ -121,7 +123,9 @@ describe("diagnostics-otel service", () => {
});
test("records message-flow metrics and spans", async () => {
const registeredTransports: Array<(logObj: Record<string, unknown>) => void> = [];
const registeredTransports: Array<
(logObj: Record<string, unknown>) => void
> = [];
const stopTransport = vi.fn();
registerLogTransportMock.mockImplementation((transport) => {
registeredTransports.push(transport);
@@ -191,23 +195,37 @@ describe("diagnostics-otel service", () => {
attempt: 2,
});
expect(telemetryState.counters.get("openclaw.webhook.received")?.add).toHaveBeenCalled();
expect(
telemetryState.counters.get("openclaw.webhook.received")?.add,
).toHaveBeenCalled();
expect(
telemetryState.histograms.get("openclaw.webhook.duration_ms")?.record,
).toHaveBeenCalled();
expect(telemetryState.counters.get("openclaw.message.queued")?.add).toHaveBeenCalled();
expect(telemetryState.counters.get("openclaw.message.processed")?.add).toHaveBeenCalled();
expect(
telemetryState.counters.get("openclaw.message.queued")?.add,
).toHaveBeenCalled();
expect(
telemetryState.counters.get("openclaw.message.processed")?.add,
).toHaveBeenCalled();
expect(
telemetryState.histograms.get("openclaw.message.duration_ms")?.record,
).toHaveBeenCalled();
expect(telemetryState.histograms.get("openclaw.queue.wait_ms")?.record).toHaveBeenCalled();
expect(telemetryState.counters.get("openclaw.session.stuck")?.add).toHaveBeenCalled();
expect(
telemetryState.histograms.get("openclaw.queue.wait_ms")?.record,
).toHaveBeenCalled();
expect(
telemetryState.counters.get("openclaw.session.stuck")?.add,
).toHaveBeenCalled();
expect(
telemetryState.histograms.get("openclaw.session.stuck_age_ms")?.record,
).toHaveBeenCalled();
expect(telemetryState.counters.get("openclaw.run.attempt")?.add).toHaveBeenCalled();
expect(
telemetryState.counters.get("openclaw.run.attempt")?.add,
).toHaveBeenCalled();
const spanNames = telemetryState.tracer.startSpan.mock.calls.map((call) => call[0]);
const spanNames = telemetryState.tracer.startSpan.mock.calls.map(
(call) => call[0],
);
expect(spanNames).toContain("openclaw.webhook.processed");
expect(spanNames).toContain("openclaw.message.processed");
expect(spanNames).toContain("openclaw.session.stuck");

View File

@@ -1,14 +1,23 @@
import type { SeverityNumber } from "@opentelemetry/api-logs";
import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk";
import type {
DiagnosticEventPayload,
OpenClawPluginService,
} from "openclaw/plugin-sdk";
import { metrics, trace, SpanStatusCode } from "@opentelemetry/api";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
import {
BatchLogRecordProcessor,
LoggerProvider,
} from "@opentelemetry/sdk-logs";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
import {
ParentBasedSampler,
TraceIdRatioBasedSampler,
} from "@opentelemetry/sdk-trace-base";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
import { onDiagnosticEvent, registerLogTransport } from "openclaw/plugin-sdk";
@@ -19,7 +28,10 @@ function normalizeEndpoint(endpoint?: string): string | undefined {
return trimmed ? trimmed.replace(/\/+$/, "") : undefined;
}
function resolveOtelUrl(endpoint: string | undefined, path: string): string | undefined {
function resolveOtelUrl(
endpoint: string | undefined,
path: string,
): string | undefined {
if (!endpoint) {
return undefined;
}
@@ -54,16 +66,23 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
return;
}
const protocol = otel.protocol ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL ?? "http/protobuf";
const protocol =
otel.protocol ??
process.env.OTEL_EXPORTER_OTLP_PROTOCOL ??
"http/protobuf";
if (protocol !== "http/protobuf") {
ctx.logger.warn(`diagnostics-otel: unsupported protocol ${protocol}`);
return;
}
const endpoint = normalizeEndpoint(otel.endpoint ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
const endpoint = normalizeEndpoint(
otel.endpoint ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
);
const headers = otel.headers ?? undefined;
const serviceName =
otel.serviceName?.trim() || process.env.OTEL_SERVICE_NAME || DEFAULT_SERVICE_NAME;
otel.serviceName?.trim() ||
process.env.OTEL_SERVICE_NAME ||
DEFAULT_SERVICE_NAME;
const sampleRate = resolveSampleRate(otel.sampleRate);
const tracesEnabled = otel.traces !== false;
@@ -140,66 +159,111 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
unit: "1",
description: "Estimated model cost (USD)",
});
const durationHistogram = meter.createHistogram("openclaw.run.duration_ms", {
unit: "ms",
description: "Agent run duration",
});
const contextHistogram = meter.createHistogram("openclaw.context.tokens", {
unit: "1",
description: "Context window size and usage",
});
const webhookReceivedCounter = meter.createCounter("openclaw.webhook.received", {
unit: "1",
description: "Webhook requests received",
});
const webhookErrorCounter = meter.createCounter("openclaw.webhook.error", {
unit: "1",
description: "Webhook processing errors",
});
const webhookDurationHistogram = meter.createHistogram("openclaw.webhook.duration_ms", {
unit: "ms",
description: "Webhook processing duration",
});
const messageQueuedCounter = meter.createCounter("openclaw.message.queued", {
unit: "1",
description: "Messages queued for processing",
});
const messageProcessedCounter = meter.createCounter("openclaw.message.processed", {
unit: "1",
description: "Messages processed by outcome",
});
const messageDurationHistogram = meter.createHistogram("openclaw.message.duration_ms", {
unit: "ms",
description: "Message processing duration",
});
const queueDepthHistogram = meter.createHistogram("openclaw.queue.depth", {
unit: "1",
description: "Queue depth on enqueue/dequeue",
});
const queueWaitHistogram = meter.createHistogram("openclaw.queue.wait_ms", {
unit: "ms",
description: "Queue wait time before execution",
});
const laneEnqueueCounter = meter.createCounter("openclaw.queue.lane.enqueue", {
unit: "1",
description: "Command queue lane enqueue events",
});
const laneDequeueCounter = meter.createCounter("openclaw.queue.lane.dequeue", {
unit: "1",
description: "Command queue lane dequeue events",
});
const sessionStateCounter = meter.createCounter("openclaw.session.state", {
unit: "1",
description: "Session state transitions",
});
const sessionStuckCounter = meter.createCounter("openclaw.session.stuck", {
unit: "1",
description: "Sessions stuck in processing",
});
const sessionStuckAgeHistogram = meter.createHistogram("openclaw.session.stuck_age_ms", {
unit: "ms",
description: "Age of stuck sessions",
});
const durationHistogram = meter.createHistogram(
"openclaw.run.duration_ms",
{
unit: "ms",
description: "Agent run duration",
},
);
const contextHistogram = meter.createHistogram(
"openclaw.context.tokens",
{
unit: "1",
description: "Context window size and usage",
},
);
const webhookReceivedCounter = meter.createCounter(
"openclaw.webhook.received",
{
unit: "1",
description: "Webhook requests received",
},
);
const webhookErrorCounter = meter.createCounter(
"openclaw.webhook.error",
{
unit: "1",
description: "Webhook processing errors",
},
);
const webhookDurationHistogram = meter.createHistogram(
"openclaw.webhook.duration_ms",
{
unit: "ms",
description: "Webhook processing duration",
},
);
const messageQueuedCounter = meter.createCounter(
"openclaw.message.queued",
{
unit: "1",
description: "Messages queued for processing",
},
);
const messageProcessedCounter = meter.createCounter(
"openclaw.message.processed",
{
unit: "1",
description: "Messages processed by outcome",
},
);
const messageDurationHistogram = meter.createHistogram(
"openclaw.message.duration_ms",
{
unit: "ms",
description: "Message processing duration",
},
);
const queueDepthHistogram = meter.createHistogram(
"openclaw.queue.depth",
{
unit: "1",
description: "Queue depth on enqueue/dequeue",
},
);
const queueWaitHistogram = meter.createHistogram(
"openclaw.queue.wait_ms",
{
unit: "ms",
description: "Queue wait time before execution",
},
);
const laneEnqueueCounter = meter.createCounter(
"openclaw.queue.lane.enqueue",
{
unit: "1",
description: "Command queue lane enqueue events",
},
);
const laneDequeueCounter = meter.createCounter(
"openclaw.queue.lane.dequeue",
{
unit: "1",
description: "Command queue lane dequeue events",
},
);
const sessionStateCounter = meter.createCounter(
"openclaw.session.state",
{
unit: "1",
description: "Session state transitions",
},
);
const sessionStuckCounter = meter.createCounter(
"openclaw.session.stuck",
{
unit: "1",
description: "Sessions stuck in processing",
},
);
const sessionStuckAgeHistogram = meter.createHistogram(
"openclaw.session.stuck_age_ms",
{
unit: "ms",
description: "Age of stuck sessions",
},
);
const runAttemptCounter = meter.createCounter("openclaw.run.attempt", {
unit: "1",
description: "Run attempts",
@@ -245,7 +309,8 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
}
| undefined;
const logLevelName = meta?.logLevelName ?? "INFO";
const severityNumber = logSeverityMap[logLevelName] ?? (9 as SeverityNumber);
const severityNumber =
logSeverityMap[logLevelName] ?? (9 as SeverityNumber);
const numericArgs = Object.entries(logObj)
.filter(([key]) => /^\d+$/.test(key))
@@ -253,10 +318,17 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
.map(([, value]) => value);
let bindings: Record<string, unknown> | undefined;
if (typeof numericArgs[0] === "string" && numericArgs[0].trim().startsWith("{")) {
if (
typeof numericArgs[0] === "string" &&
numericArgs[0].trim().startsWith("{")
) {
try {
const parsed = JSON.parse(numericArgs[0]);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
if (
parsed &&
typeof parsed === "object" &&
!Array.isArray(parsed)
) {
bindings = parsed as Record<string, unknown>;
numericArgs.shift();
}
@@ -266,7 +338,10 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
}
let message = "";
if (numericArgs.length > 0 && typeof numericArgs[numericArgs.length - 1] === "string") {
if (
numericArgs.length > 0 &&
typeof numericArgs[numericArgs.length - 1] === "string"
) {
message = String(numericArgs.pop());
} else if (numericArgs.length === 1) {
message = safeStringify(numericArgs[0]);
@@ -330,7 +405,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
durationMs?: number,
) => {
const startTime =
typeof durationMs === "number" ? Date.now() - Math.max(0, durationMs) : undefined;
typeof durationMs === "number"
? Date.now() - Math.max(0, durationMs)
: undefined;
const span = tracer.startSpan(name, {
attributes,
...(startTime ? { startTime } : {}),
@@ -338,7 +415,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
return span;
};
const recordModelUsage = (evt: Extract<DiagnosticEventPayload, { type: "model.usage" }>) => {
const recordModelUsage = (
evt: Extract<DiagnosticEventPayload, { type: "model.usage" }>,
) => {
const attrs = {
"openclaw.channel": evt.channel ?? "unknown",
"openclaw.provider": evt.provider ?? "unknown",
@@ -347,22 +426,40 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
const usage = evt.usage;
if (usage.input) {
tokensCounter.add(usage.input, { ...attrs, "openclaw.token": "input" });
tokensCounter.add(usage.input, {
...attrs,
"openclaw.token": "input",
});
}
if (usage.output) {
tokensCounter.add(usage.output, { ...attrs, "openclaw.token": "output" });
tokensCounter.add(usage.output, {
...attrs,
"openclaw.token": "output",
});
}
if (usage.cacheRead) {
tokensCounter.add(usage.cacheRead, { ...attrs, "openclaw.token": "cache_read" });
tokensCounter.add(usage.cacheRead, {
...attrs,
"openclaw.token": "cache_read",
});
}
if (usage.cacheWrite) {
tokensCounter.add(usage.cacheWrite, { ...attrs, "openclaw.token": "cache_write" });
tokensCounter.add(usage.cacheWrite, {
...attrs,
"openclaw.token": "cache_write",
});
}
if (usage.promptTokens) {
tokensCounter.add(usage.promptTokens, { ...attrs, "openclaw.token": "prompt" });
tokensCounter.add(usage.promptTokens, {
...attrs,
"openclaw.token": "prompt",
});
}
if (usage.total) {
tokensCounter.add(usage.total, { ...attrs, "openclaw.token": "total" });
tokensCounter.add(usage.total, {
...attrs,
"openclaw.token": "total",
});
}
if (evt.costUsd) {
@@ -398,7 +495,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
"openclaw.tokens.total": usage.total ?? 0,
};
const span = spanWithDuration("openclaw.model.usage", spanAttrs, evt.durationMs);
const span = spanWithDuration(
"openclaw.model.usage",
spanAttrs,
evt.durationMs,
);
span.end();
};
@@ -429,7 +530,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
if (evt.chatId !== undefined) {
spanAttrs["openclaw.chatId"] = String(evt.chatId);
}
const span = spanWithDuration("openclaw.webhook.processed", spanAttrs, evt.durationMs);
const span = spanWithDuration(
"openclaw.webhook.processed",
spanAttrs,
evt.durationMs,
);
span.end();
};
@@ -501,7 +606,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
if (evt.reason) {
spanAttrs["openclaw.reason"] = evt.reason;
}
const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs);
const span = spanWithDuration(
"openclaw.message.processed",
spanAttrs,
evt.durationMs,
);
if (evt.outcome === "error") {
span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error });
}
@@ -557,19 +666,28 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
}
spanAttrs["openclaw.queueDepth"] = evt.queueDepth ?? 0;
spanAttrs["openclaw.ageMs"] = evt.ageMs;
const span = tracer.startSpan("openclaw.session.stuck", { attributes: spanAttrs });
span.setStatus({ code: SpanStatusCode.ERROR, message: "session stuck" });
const span = tracer.startSpan("openclaw.session.stuck", {
attributes: spanAttrs,
});
span.setStatus({
code: SpanStatusCode.ERROR,
message: "session stuck",
});
span.end();
};
const recordRunAttempt = (evt: Extract<DiagnosticEventPayload, { type: "run.attempt" }>) => {
const recordRunAttempt = (
evt: Extract<DiagnosticEventPayload, { type: "run.attempt" }>,
) => {
runAttemptCounter.add(1, { "openclaw.attempt": evt.attempt });
};
const recordHeartbeat = (
evt: Extract<DiagnosticEventPayload, { type: "diagnostic.heartbeat" }>,
) => {
queueDepthHistogram.record(evt.queued, { "openclaw.channel": "heartbeat" });
queueDepthHistogram.record(evt.queued, {
"openclaw.channel": "heartbeat",
});
};
unsubscribe = onDiagnosticEvent((evt: DiagnosticEventPayload) => {

View File

@@ -31,8 +31,10 @@ import { getDiscordRuntime } from "./runtime.js";
const meta = getChatChannelMeta("discord");
const discordMessageActions: ChannelMessageActionAdapter = {
listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions.listActions(ctx),
extractToolSend: (ctx) => getDiscordRuntime().channel.discord.messageActions.extractToolSend(ctx),
listActions: (ctx) =>
getDiscordRuntime().channel.discord.messageActions.listActions(ctx),
extractToolSend: (ctx) =>
getDiscordRuntime().channel.discord.messageActions.extractToolSend(ctx),
handleAction: async (ctx) =>
await getDiscordRuntime().channel.discord.messageActions.handleAction(ctx),
};
@@ -68,7 +70,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
config: {
listAccountIds: (cfg) => listDiscordAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
resolveAccount: (cfg, accountId) =>
resolveDiscordAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
@@ -94,9 +97,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? []).map((entry) =>
String(entry),
),
(
resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? []
).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
@@ -105,8 +108,11 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(cfg.channels?.discord?.accounts?.[resolvedAccountId]);
const resolvedAccountId =
accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
cfg.channels?.discord?.accounts?.[resolvedAccountId],
);
const allowFromPath = useAccountPath
? `channels.discord.accounts.${resolvedAccountId}.dm.`
: "channels.discord.dm.";
@@ -115,13 +121,15 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
allowFrom: account.config.dm?.allowFrom ?? [],
allowFromPath,
approveHint: formatPairingApproveHint("discord"),
normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
normalizeEntry: (raw) =>
raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
};
},
collectWarnings: ({ account, cfg }) => {
const warnings: string[] = [];
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
const groupPolicy =
account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
const guildEntries = account.config.guilds ?? {};
const guildsConfigured = Object.keys(guildEntries).length > 0;
const channelAllowlistConfigured = guildsConfigured;
@@ -149,7 +157,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
stripPatterns: () => ["<@!?\\d+>"],
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off",
resolveReplyToMode: ({ cfg }) =>
cfg.channels?.discord?.replyToMode ?? "off",
},
messaging: {
normalizeTarget: normalizeDiscordMessagingTarget,
@@ -179,10 +188,11 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
}));
}
if (kind === "group") {
const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({
token,
entries: inputs,
});
const resolved =
await getDiscordRuntime().channel.discord.resolveChannelAllowlist({
token,
entries: inputs,
});
return resolved.map((entry) => ({
input: entry.input,
resolved: entry.resolved,
@@ -194,10 +204,11 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
note: entry.note,
}));
}
const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({
token,
entries: inputs,
});
const resolved =
await getDiscordRuntime().channel.discord.resolveUserAllowlist({
token,
entries: inputs,
});
return resolved.map((entry) => ({
input: entry.input,
resolved: entry.resolved,
@@ -248,7 +259,11 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
discord: {
...next.channels?.discord,
enabled: true,
...(input.useEnv ? {} : input.token ? { token: input.token } : {}),
...(input.useEnv
? {}
: input.token
? { token: input.token }
: {}),
},
},
};
@@ -279,7 +294,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
textChunkLimit: 2000,
pollMaxOptions: 10,
sendText: async ({ to, text, accountId, deps, replyToId }) => {
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
const send =
deps?.sendDiscord ??
getDiscordRuntime().channel.discord.sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
replyTo: replyToId ?? undefined,
@@ -288,7 +305,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
return { channel: "discord", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
const send =
deps?.sendDiscord ??
getDiscordRuntime().channel.discord.sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
mediaUrl,
@@ -322,9 +341,13 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
includeApplication: true,
}),
getDiscordRuntime().channel.discord.probeDiscord(
account.token,
timeoutMs,
{
includeApplication: true,
},
),
auditAccount: async ({ account, timeoutMs, cfg }) => {
const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
cfg,
@@ -343,17 +366,20 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
elapsedMs: 0,
};
}
const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({
token: botToken,
accountId: account.accountId,
channelIds,
timeoutMs,
});
const audit =
await getDiscordRuntime().channel.discord.auditChannelPermissions({
token: botToken,
accountId: account.accountId,
channelIds,
timeoutMs,
});
return { ...audit, unresolvedChannels };
},
buildAccountSnapshot: ({ account, runtime, probe, audit }) => {
const configured = Boolean(account.token?.trim());
const app = runtime?.application ?? (probe as { application?: unknown })?.application;
const app =
runtime?.application ??
(probe as { application?: unknown })?.application;
const bot = runtime?.bot ?? (probe as { bot?: unknown })?.bot;
return {
accountId: account.accountId,
@@ -380,9 +406,13 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
const token = account.token.trim();
let discordBotLabel = "";
try {
const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, {
includeApplication: true,
});
const probe = await getDiscordRuntime().channel.discord.probeDiscord(
token,
2500,
{
includeApplication: true,
},
);
const username = probe.ok ? probe.bot?.username?.trim() : null;
if (username) {
discordBotLabel = ` (@${username})`;
@@ -404,10 +434,14 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
}
} catch (err) {
if (getDiscordRuntime().logging.shouldLogVerbose()) {
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
ctx.log?.debug?.(
`[${account.accountId}] bot probe failed: ${String(err)}`,
);
}
}
ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
ctx.log?.info(
`[${account.accountId}] starting provider${discordBotLabel}`,
);
return getDiscordRuntime().channel.discord.monitorDiscordProvider({
token,
accountId: account.accountId,

Some files were not shown because too many files have changed in this diff Show More