mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
chore: fix code formatting
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") ?? ""
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)"]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/"),
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 OpenClaw’s 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 OpenClaw’s 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:<port>/</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:<port>/</code>. Only
|
||||
change this if your OpenClaw profile uses a different
|
||||
<code>cdpUrl</code> port.
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
|
||||
@@ -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 OpenClaw’s 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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
32
docs/pi.md
32
docs/pi.md
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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. **服务器行为**
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 快照。
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
// 不要抛出异常 - 让其他处理器继续运行
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(实验性)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}.`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
|
||||
@@ -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"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: "" };
|
||||
|
||||
@@ -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(/\/+$/, "");
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
{},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user