diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd25e70..9e0e198 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,21 +12,21 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.25.0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: packages/pickle/native/go.mod cache-dependency-path: packages/pickle/native/go.sum @@ -37,15 +37,15 @@ jobs: - name: Typecheck run: pnpm typecheck + - name: Build + run: pnpm build + - name: Test run: pnpm test - name: Go test run: pnpm test:go - - name: Build - run: pnpm build - - name: Pack packages run: pnpm pack:packages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87732fe..add90cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,24 +20,24 @@ jobs: pull-requests: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.25.0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 registry-url: https://registry.npmjs.org cache: pnpm - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: packages/pickle/native/go.mod cache-dependency-path: packages/pickle/native/go.sum diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d7d4b85 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# OpenClaw plugin + +Use Crabpot for OpenClaw plugin and integration compatibility testing. Crabpot is the OpenClaw compatibility testbed for community plugins, plugin API seams, channel registration, lifecycle hooks, provider capabilities, cold imports, workspace planning, and static execution policy. + +Expected sibling checkouts: + +```sh +/Users/batuhan/Projects/pickle +/Users/batuhan/Projects/crabpot +/Users/batuhan/Projects/openclaw +``` + +If Crabpot is missing, set it up from the Pickle repo with: + +```sh +git clone https://github.com/openclaw/crabpot.git ../crabpot +npm --prefix ../crabpot install +npm --prefix ../crabpot test +``` + +Crabpot defaults to `../openclaw` for source-mode OpenClaw checks. Clone `https://github.com/openclaw/openclaw.git` there when the checkout is missing, or set `CRABPOT_DIR=/path/to/crabpot` when Crabpot lives elsewhere. + +Run the full Pickle and OpenClaw plugin compatibility suite with: + +```sh +npm run full-test +``` + +That runs Pickle's existing `pnpm check` first, then `npm run check` in Crabpot through `scripts/openclaw-crabpot-full-test.mjs`. + +Useful narrower commands: + +```sh +npm run test:openclaw:plugins +npm --prefix ../crabpot run check +npm --prefix ../crabpot run report -- --check +npm --prefix ../crabpot run workspace:plan +``` + +Crabpot default checks are credential-free. Do not run opt-in isolated execution commands unless the task explicitly needs side effects, for example `CRABPOT_EXECUTE_ISOLATED=1 npm --prefix ../crabpot run workspace:execute -- --fixture `. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7300f70..755fa10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,3 +7,7 @@ - Node file storage, Cloudflare KV storage, and Durable Object storage helpers. - Matrix messages, room threads, reactions, media, DMs, typing, read receipts, long polling, and Chat SDK adapter mapping. +- OpenClaw Beeper channel plugin for exposing OpenClaw agents and sessions over + Beeper/Matrix, including setup metadata, appservice registration, agent ghost + contacts, command discovery, approval handling, and native Beeper turn + streaming. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e170b09..84650d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,7 @@ Requires Node 22+, pnpm 9+, and a Go toolchain. pnpm typecheck pnpm test pnpm build -go test ./... # run from packages/pickle/native +pnpm test:go # runs Pickle's Go tests with the goolm build tag ``` ## Release @@ -32,6 +32,11 @@ When changes land on `main`, GitHub Actions opens or updates a release PR. Merging that release PR runs the full `pnpm check` gate, publishes changed packages with `pnpm changeset publish`, and creates GitHub Releases. +For the initial public release, package manifests already start at `0.1.0`. +Publish those unpublished `0.1.0` packages directly through the release +workflow; after that first publish, every user-facing package change should +carry a normal changeset. + Publishing uses npm Trusted Publishing through GitHub Actions OIDC. Each npm package must configure this trusted publisher on npmjs.com: diff --git a/OPENCLAW_BEEPER_HANDOFF.md b/OPENCLAW_BEEPER_HANDOFF.md new file mode 100644 index 0000000..9cc19f0 --- /dev/null +++ b/OPENCLAW_BEEPER_HANDOFF.md @@ -0,0 +1,303 @@ +# OpenClaw Beeper Handoff + +Date: 2026-06-02 +Workspace: `/Users/batuhan/Projects/pickle` + +## Installed Locations + +- Pickle/OpenClaw bridge workspace: `/Users/batuhan/Projects/pickle` +- Live installed OpenClaw plugin: `/Users/batuhan/.openclaw/extensions/beeper` +- OpenClaw app source for reference: `/Users/batuhan/Projects/openclaw` +- ai-bridge source for reference and shared-package changes: `/Users/batuhan/Projects/ai-bridge` +- mautrix bridge examples/reference: `/Users/batuhan/Projects/mautrix` +- plugin validation harness: `/Users/batuhan/Projects/crabpot` + +Live channel status at handoff: + +```sh +openclaw channels status +``` + +Reports: + +- Gateway reachable. +- Beeper default: enabled, configured, running. +- Telegram default: enabled, configured, running, connected. This is unrelated to the Beeper/OpenClaw bridge work. + +## Original Product Goal + +Build `@beeper/openclaw` as a first-class Beeper plugin backed by Pickle and bridgev2 semantics. + +Target model: + +- `UserLogin`: one Beeper/OpenClaw account/device. +- `Ghost`: one global OpenClaw agent user. +- `Portal`: one Matrix/Beeper conversation. +- `Session`: OpenClaw runtime state attached to a portal only after the first real user turn. + +Expected behavior: + +- Each configured OpenClaw agent gets a stable global ghost: + `@_agent_:`. +- Each agent gets one welcome DM portal on connect. +- Users can start new DMs/groups with those ghost users. +- A new DM/group creates or claims a portal. +- The first real user message in that portal creates an OpenClaw session and persists `sessionKey` on the portal binding. +- Importing old OpenClaw sessions is explicitly later work and should not shape the core bridge. + +Constraints from Batuhan: + +- Use bridgev2/mautrix mental model as much as possible. +- Move as much logic as possible into Go and generated Go contracts. +- Reuse ai-bridge packages heavily, but do not import ai-bridge `internal` or connector-specific code. +- Keep code simple: no fake layers, no duplicate types, no barrel exports for convenience, no backcompat, no legacy migration baggage. +- Prefer deleting/collapsing code over preserving AI-generated parallel paths. +- Beeper-only setup. Do not expose homeserver/domain/token/appservice id as ordinary user settings. +- Product intent beats current code shape. + +## Current Implementation Status + +Important current git state: + +```sh +git status --short +``` + +At handoff, modified files include: + +- `packages/bridge/src/bridge.ts` +- `packages/bridge/src/bridge.test.ts` +- `packages/bridge/src/index.ts` +- `packages/openclaw/src/bridge-agent.ts` +- `packages/openclaw/src/connector.ts` +- `packages/openclaw/src/connector.test.ts` +- `packages/openclaw/src/openclaw-runtime.ts` + +Do not assume all modifications are from the last agent turn. The tree has been evolving across multiple turns. + +### ai-bridge Dependency + +Pickle native Go helpers are pinned to the latest local ai-bridge commit we used: + +```text +github.com/beeper/ai-bridge v0.0.0-20260602005818-ab83be648105 +``` + +Local ai-bridge HEAD: + +```text +ab83be648105 / ab83be64 Preserve tool metadata in final AI parts +``` + +This is in `packages/pickle/native/go.mod`. `go.sum` still contains the older pseudo-version too, which is normal unless cleaned by `go mod tidy`. + +### Streaming / Beeper AI + +Main stream logic lives in: + +- Go/native: `packages/pickle/native/internal/core/beeper_ai_run.go` +- TS bridge stream adapter: `packages/bridge/src/beeper-stream.ts` +- OpenClaw runtime mapper: `packages/openclaw/src/openclaw-runtime.ts` +- OpenClaw channel runtime: `packages/openclaw/src/beeper-channel-runtime.ts` + +Current state: + +- Native Go path uses ai-bridge writer/projection/finalization. +- Finalization and large final parts upload are in Go through ai-bridge projection helpers. +- Semantic parts include text, reasoning, tool start/input/result, activity, state, raw/custom events. +- Tool result mapping now prefers actual stdout/stderr/response over status wrapper objects. +- Working placeholder suppression exists in both text and activity paths. The latest edit suppresses activity events whose visible text is exactly `Working...`. +- Pending tools are waited on before finalization in the TS runtime path. +- Final projection clears ai-bridge `Working...` fallback bodies for empty streams. + +Known streaming gaps: + +- Need live Beeper Desktop confirmation that rotating progress verbs render exactly like ai-bridge. +- Reasoning/thinking tokens depend on what OpenClaw emits. The bridge now maps reasoning events, but if OpenClaw emits no reasoning stream, Beeper will show none. +- The current rich stream mapper is still partly TypeScript. The long-term direction is more generated Go contracts and less TS mapping. +- Need compare against ai-bridge stream semantics again before calling this done, especially sequence ordering and final replacement behavior. + +### Ghosts / Portals / Sessions + +Main files: + +- `packages/openclaw/src/connector.ts` +- `packages/openclaw/src/bridge-agent.ts` +- `packages/openclaw/src/registry.ts` +- `packages/openclaw/src/rooms.ts` + +Current state: + +- Agent ghosts are registered globally from OpenClaw agents. +- Agent contacts are exposed through contact list / identifier resolving. +- Each agent gets a welcome DM portal on connect. +- Welcome portal bindings are `kind: "agent"` with placeholder `sessionKey: "agent:"`. +- On first real user turn, `OpenClawMatrixBridgeAgent.ensureSession()` creates a real OpenClaw session and now changes the binding to `kind: "session"`. +- The first user message in a welcome room therefore transitions from agent/welcome binding to a real session binding. +- Users can resolve agent ghosts and create fresh DMs. + +Known portal/session gaps: + +- Need deeper audit against bridgev2 examples in WhatsApp/Telegram/Signal for exact portal claiming, invite/group handling, and room metadata lifecycle. +- Need live test starting a new DM/group with an agent ghost from Beeper Desktop. +- Need ensure reconnect does not create duplicate welcome DMs after an agent binding has become a session binding. Current code uses the agent binding as the welcome-room marker, so this area needs attention after the new transition. +- Need improve ghost avatar/name syncing from OpenClaw agent metadata and verify desktop displays them correctly. + +### Slash Commands + +ai-bridge handles slash commands before the AI turn and replies with a command notice. OpenClaw previously parsed slash commands but still passed the slash text to the agent as prompt text. + +Current state: + +- `packages/openclaw/src/matrix-parser.ts` parses slash commands. +- `packages/openclaw/src/connector.ts` now intercepts OpenClaw `/help` and `/session`. +- `/session` is sent as an `m.notice` from the agent ghost or service bot, not as an AI turn. +- `/session` in a welcome room reports that no real session has started yet and does not create one. +- Unknown slash commands still fall through to OpenClaw as agent text for now. + +Known command gaps: + +- Need implement real `/stop`/`/abort` only after finding or adding a real OpenClaw cancellation primitive. Do not fake cancellation. +- Need decide which commands belong to OpenClaw channel runtime vs bridge control. +- Need richer formatting if Beeper Desktop supports a better command-result surface than `m.notice` HTML. + +### Config / Setup + +Current public Beeper channel settings are intentionally minimal: + +- `enabled` +- `beeperEnv` with production as default + +Public schema: + +- `packages/openclaw/src/beeper-channel-config.schema.json` +- `packages/openclaw/openclaw.plugin.json` + +Hidden persisted setup still includes appservice/homeserver/tokens/device data under channel settings. Do not expose these as normal user settings. + +Login/setup direction: + +- Email login is default. +- Username/password is optional. +- Token auth should be removed from user-facing setup. +- The bridge owns and persists its Beeper device. +- Appservice / bridge id should be derived per device. Do not ask for values that can be derived. +- `mode: "self-hosted-appservice"` and `registrationUrl: "websocket"` should be hardcoded through bridgev2/Pickle defaults, not user-configurable. + +## Bridge Manager / Appservice Flow + +Bridge-manager helper code: + +- `packages/bridge/src/beeper.ts` +- Exports from `packages/bridge/src/index.ts` + +The helper mirrors useful `bbctl whoami/register` pieces: + +- `createBeeperBridgeManagerClient({ token })` +- `fetchBeeperBridges({ token })` +- `createBeeperAppService({ token, bridge })` +- `createBeeperAppServiceInit({ token, bridge })` + +Flow: + +1. Call Beeper API `https://api./whoami`. +2. Get username and bridge-manager/Hungryserv metadata. +3. Register or fetch appservice through Hungryserv: + `/_matrix/asmux/mxauth/appservice/:user/:bridge`. +4. Register with `self_hosted: true`, `receive_ephemeral: true`. +5. Post bridge state back to Beeper bridgebox with state events like `STARTING`, `RUNNING`, etc. +6. Produce `MatrixAppserviceInitOptions` with homeserver, homeserver domain, and registration tokens. + +Runtime startup: + +- OpenClaw channel startup is in `packages/openclaw/src/setup.ts`. +- It calls `startOpenClawBeeperBridge()` from `packages/openclaw/src/appservice.ts`. +- `startOpenClawBeeperBridge()` creates the Pickle bridge, starts it, and marks bridge state running. +- `packages/bridge/src/bridge.ts` boots Matrix, initializes appservice, loads persisted portals/logins, subscribes Matrix events, and starts websocket appservice transaction handling. +- If appservice registration URL is websocket/self-hosted, `AppserviceWebsocket` receives appservice transactions and feeds Matrix events into the bridge connector. + +## Restart / Live Sync Commands + +After changing `packages/openclaw`, rebuild and sync the installed plugin: + +```sh +pnpm --filter @beeper/openclaw build +rsync -a --delete --exclude node_modules /Users/batuhan/Projects/pickle/packages/openclaw/ /Users/batuhan/.openclaw/extensions/beeper/ +openclaw plugins registry --refresh +openclaw gateway restart +openclaw channels status +``` + +If native Go/Pickle code changes, build Pickle too: + +```sh +pnpm --filter @beeper/pickle build +pnpm --filter @beeper/openclaw build +rsync -a --delete --exclude node_modules /Users/batuhan/Projects/pickle/packages/openclaw/ /Users/batuhan/.openclaw/extensions/beeper/ +openclaw plugins registry --refresh +openclaw gateway restart +openclaw channels status +``` + +The `rsync --delete` command overwrites the live installed plugin from the workspace package while preserving `node_modules`. + +## Useful Validation Commands + +Focused tests that passed most recently: + +```sh +pnpm --filter @beeper/openclaw test -- src/connector.test.ts src/openclaw-runtime.test.ts +``` + +Result: 16 files passed, 129 tests passed. + +Native Go tests used earlier: + +```sh +cd /Users/batuhan/Projects/pickle/packages/pickle/native +go test ./internal/core -run 'TestBeeperAIRun|TestAppservice' +``` + +OpenClaw broader tests used earlier: + +```sh +pnpm --filter @beeper/openclaw test -- src/openclaw-extension.test.ts src/setup.test.ts src/config.test.ts src/beeper-setup.test.ts src/appservice.test.ts +pnpm --filter @beeper/openclaw build +``` + +Current warning: + +```sh +pnpm --filter @beeper/openclaw typecheck +``` + +Currently fails in `packages/bridge/src/bridge.ts` and `packages/bridge/src/events.ts` with exact optional property/type errors. This was observed after the latest slash/stream edits. Do not treat the typecheck baseline as clean until those bridge-package errors are handled. + +## Recent Decisions + +- Do not include Beeper account secrets in repo docs or config. Credentials were only provided in chat for live login. +- Do not expose homeserver, domain, hs/as token, appservice id, bridge id, Matrix device id, import/backfill, or approval behavior as public OpenClaw channel settings. +- Keep `additionalProperties: true` in the channel schema for hidden setup state, but public schema and manifest only advertise user-facing settings. +- Slash commands that are bridge commands should not enter the agent prompt. +- Do not implement `/stop` as a fake bridge notice. It needs a real OpenClaw cancel API. +- Preserve ai-bridge semantics for streaming and finalization; if behavior differs from ai-bridge, treat that as a bug unless product intent says otherwise. +- Use actual tool output in Beeper UI, not wrapper/status-only payloads. +- Welcome room is not a session until first real user turn. +- Reasoning is enabled for Beeper sessions by patching OpenClaw sessions with `reasoningLevel: "on"` where supported. + +## Highest Priority Next Gaps + +1. Fix `@beeper/openclaw typecheck` by cleaning the bridge package type errors. +2. Re-run build and sync live plugin. +3. Live-test from Beeper Desktop: + - fresh welcome DM per agent; + - first real message creates one session; + - `/session` is a notice and not an AI prompt; + - no visible `Working...` flash; + - tool parts stream in order; + - command output shows actual stdout/response; + - search/fetch/source results render with rich parts. +4. Compare connector lifecycle against `/Users/batuhan/Projects/mautrix` WhatsApp/Telegram/Signal examples and bridgev2 expectations. +5. Move more stream/finalization/contract logic into Go and generated types. +6. Add real cancellation support if OpenClaw exposes or can expose an active-run abort method. +7. Revisit welcome-room marker logic after `kind: "agent"` transitions to `kind: "session"` so reconnect does not create duplicate welcome rooms. diff --git a/examples/dummybridge/src/connector.ts b/examples/dummybridge/src/connector.ts index cc41997..9f46361 100644 --- a/examples/dummybridge/src/connector.ts +++ b/examples/dummybridge/src/connector.ts @@ -1,4 +1,4 @@ -import { createRemoteMessage } from "@beeper/pickle-bridge"; +import { createRemoteMessage } from "@beeper/pickle-bridge/events"; import type { BridgeConfigPart, BridgeContext, @@ -154,10 +154,10 @@ export class DummyConnector implements CommandHandlingBridgeConnector { return reply(ctx.bridge.ghostUserId(localId)); } case "kick-me": - await ctx.client.raw.request({ - body: { reason: "DummyBridge kick-me command", user_id: command.sender.userId }, - method: "POST", - path: `/_matrix/client/v3/rooms/${encodeURIComponent(command.room.mxid)}/kick`, + await ctx.client.rooms.kick({ + reason: "DummyBridge kick-me command", + roomId: command.room.mxid, + userId: command.sender.userId, }); return { handled: true }; case "file": diff --git a/examples/dummybridge/src/index.ts b/examples/dummybridge/src/index.ts index ed77f13..b921f10 100644 --- a/examples/dummybridge/src/index.ts +++ b/examples/dummybridge/src/index.ts @@ -1,7 +1,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { loginWithPassword } from "@beeper/pickle/auth"; -import { createBeeperBridge } from "@beeper/pickle-bridge"; +import { createBeeperBridge } from "@beeper/pickle-bridge/node"; import type { CreateNodeBeeperBridgeOptions, Portal } from "@beeper/pickle-bridge/types"; import { DUMMY_CHAT_IDS, DummyConnector, LOGIN_ID, PORTAL_ID } from "./connector"; import { loadEnv, optionalEnv, requiredEnv } from "./env"; diff --git a/examples/dummybridge/test/smoke.ts b/examples/dummybridge/test/smoke.ts index b207bf2..86c2525 100644 --- a/examples/dummybridge/test/smoke.ts +++ b/examples/dummybridge/test/smoke.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { RuntimeBridge } from "@beeper/pickle-bridge"; +import { RuntimeBridge } from "@beeper/pickle-bridge/bridge"; import type { MatrixClient, MatrixClientEvent, MatrixStore } from "@beeper/pickle"; import type { BridgeConnector, BridgeMatrixConfig, MatrixAppserviceInitOptions } from "@beeper/pickle-bridge/types"; import { DummyConnector, LOGIN_ID, PORTAL_ID } from "../src/connector"; diff --git a/package.json b/package.json index 8f8915e..42dded2 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "pickle-monorepo", + "name": "@beeper/pickle-workspace", "private": true, "type": "module", "packageManager": "pnpm@10.25.0", @@ -7,15 +7,17 @@ "build": "pnpm -r build", "build:wasm": "pnpm --filter @beeper/pickle build:wasm", "audit:surface": "node scripts/audit-package-surface.mjs", - "check": "pnpm audit:surface && pnpm typecheck && pnpm test && pnpm test:go && pnpm build && pnpm pack:packages && pnpm smoke:consumer && pnpm smoke:cloudflare", + "check": "pnpm audit:surface && pnpm typecheck && pnpm build && pnpm test && pnpm test:go && pnpm pack:packages && pnpm smoke:consumer && pnpm smoke:cloudflare", "clean": "pnpm -r clean", "changeset": "changeset", - "pack:packages": "mkdir -p .packs && pnpm -r --filter './packages/*' pack --pack-destination ./.packs", + "full-test": "pnpm check && pnpm test:openclaw:plugins", + "pack:packages": "rm -rf .packs && mkdir -p .packs && pnpm -r --filter './packages/*' pack --pack-destination ./.packs", "release": "pnpm check && pnpm changeset publish", "smoke:cloudflare": "node scripts/smoke-cloudflare-worker.mjs", "smoke:consumer": "node scripts/package-consumer-smoke.mjs", "smoke:package-consumer": "node scripts/package-consumer-smoke.mjs", - "test:go": "cd packages/pickle/native && go test -tags goolm ./...", + "test:go": "pnpm --filter @beeper/pickle test:go", + "test:openclaw:plugins": "node scripts/openclaw-crabpot-full-test.mjs", "test:e2e": "pnpm build && pnpm --dir e2e test", "test:e2e:adapter": "pnpm build && pnpm --dir e2e test:adapter", "test:e2e:browser:serve": "pnpm --dir e2e test:browser:serve", diff --git a/packages/bridge/README.md b/packages/bridge/README.md index af83331..97d986e 100644 --- a/packages/bridge/README.md +++ b/packages/bridge/README.md @@ -6,7 +6,7 @@ bridgev2-shaped connector interfaces and bridge runtime orchestration. ```ts import { loginWithPassword } from "@beeper/pickle/auth"; -import { createBeeperBridge } from "@beeper/pickle-bridge"; +import { createBeeperBridge } from "@beeper/pickle-bridge/node"; import type { BridgeConnector } from "@beeper/pickle-bridge/types"; const account = await loginWithPassword({ @@ -76,7 +76,7 @@ The bridge package is Node-only and uses the same Pickle WASM mechanism as ## Bridge-manager helpers -`@beeper/pickle-bridge` also exposes bridge-manager-compatible helpers: +`@beeper/pickle-bridge/beeper` exposes bridge-manager-compatible helpers: - `createBeeperBridgeManagerClient({ token })` - `fetchBeeperBridges({ token })` diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 2be8fa5..c9fca08 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -12,18 +12,35 @@ "bugs": { "url": "https://github.com/beeper/pickle/issues" }, - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, "./beeper": { "types": "./dist/beeper.d.ts", "import": "./dist/beeper.js" }, + "./beeper-stream": { + "types": "./dist/beeper-stream.d.ts", + "import": "./dist/beeper-stream.js" + }, + "./bridge": { + "types": "./dist/bridge.d.ts", + "import": "./dist/bridge.js" + }, + "./events": { + "types": "./dist/events.d.ts", + "import": "./dist/events.js" + }, + "./media-message": { + "types": "./dist/media-message.d.ts", + "import": "./dist/media-message.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.js" + }, + "./room-state": { + "types": "./dist/room-state.d.ts", + "import": "./dist/room-state.js" + }, "./store": { "types": "./dist/store.d.ts", "import": "./dist/store.js" diff --git a/packages/bridge/src/appservice-websocket.test.ts b/packages/bridge/src/appservice-websocket.test.ts index c4aa237..477cada 100644 --- a/packages/bridge/src/appservice-websocket.test.ts +++ b/packages/bridge/src/appservice-websocket.test.ts @@ -16,13 +16,13 @@ afterEach(async () => { }); describe("AppserviceWebsocket", () => { - it("connects to as_sync, dispatches transactions, and acknowledges them", async () => { + it("connects to as_sync, forwards transactions, and acknowledges them", async () => { const httpServer = createServer(); const wsServer = new WebSocketServer({ server: httpServer }); servers.push(wsServer, httpServer); await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); const homeserver = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}/_hungryserv/alice`; - const dispatch = vi.fn(async () => {}); + const handleTransaction = vi.fn(async () => {}); const connected = new Promise((resolve, reject) => { wsServer.on("connection", (socket, request) => { try { @@ -55,7 +55,7 @@ describe("AppserviceWebsocket", () => { }); }); const websocket = createWebsocket(homeserver, { - dispatch, + handleTransaction, log: (() => {}) as BridgeLogger, }); websockets.push(websocket); @@ -63,11 +63,13 @@ describe("AppserviceWebsocket", () => { websocket.start(); await connected; - expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ - eventId: "$event", - kind: "message", - roomId: "!room:example", - text: "hi", + expect(handleTransaction).toHaveBeenCalledWith(expect.objectContaining({ + events: [expect.objectContaining({ + event_id: "$event", + room_id: "!room:example", + type: "m.room.message", + })], + txn_id: "txn-1", })); }); @@ -147,7 +149,6 @@ describe("AppserviceWebsocket", () => { servers.push(wsServer, httpServer); await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); const homeserver = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}/_hungryserv/alice`; - const dispatch = vi.fn(async () => {}); const handleTransaction = vi.fn(async () => {}); const connected = new Promise((resolve, reject) => { wsServer.on("connection", (socket) => { @@ -183,7 +184,6 @@ describe("AppserviceWebsocket", () => { }); }); const websocket = createWebsocket(homeserver, { - dispatch, handleTransaction, log: (() => {}) as BridgeLogger, }); @@ -192,11 +192,6 @@ describe("AppserviceWebsocket", () => { websocket.start(); await connected; - expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ - eventId: "$proxied", - kind: "message", - text: "proxied", - })); expect(handleTransaction).toHaveBeenCalledWith(expect.objectContaining({ events: [expect.objectContaining({ event_id: "$proxied" })], txn_id: "txn-2", @@ -327,7 +322,6 @@ function createWebsocket( url: "", }, }, - dispatch: vi.fn(async () => {}), log: (() => {}) as BridgeLogger, ...overrides, }); diff --git a/packages/bridge/src/appservice-websocket.ts b/packages/bridge/src/appservice-websocket.ts index 0655d2a..cf3021f 100644 --- a/packages/bridge/src/appservice-websocket.ts +++ b/packages/bridge/src/appservice-websocket.ts @@ -1,10 +1,9 @@ import WebSocket from "ws"; -import type { MatrixAppserviceInitOptions, MatrixClientEvent } from "@beeper/pickle"; +import type { MatrixAppserviceInitOptions } from "@beeper/pickle"; import type { BridgeLogger } from "./types"; export interface AppserviceWebsocketOptions { appservice: MatrixAppserviceInitOptions; - dispatch(event: MatrixClientEvent): Promise; handleHTTPProxy?(request: HTTPProxyRequest): Promise; handleTransaction?(transaction: Record): Promise; log: BridgeLogger; @@ -40,7 +39,6 @@ export class AppserviceWebsocket { }; readonly #appservice: MatrixAppserviceInitOptions; - readonly #dispatch: (event: MatrixClientEvent) => Promise; readonly #handleProxy: ((request: HTTPProxyRequest) => Promise) | undefined; readonly #handleTransaction: ((transaction: Record) => Promise) | undefined; readonly #log: BridgeLogger; @@ -61,7 +59,6 @@ export class AppserviceWebsocket { constructor(options: AppserviceWebsocketOptions) { this.#appservice = options.appservice; - this.#dispatch = options.dispatch; this.#handleProxy = options.handleHTTPProxy; this.#handleTransaction = options.handleTransaction; this.#log = options.log; @@ -201,7 +198,19 @@ export class AppserviceWebsocket { } async #handleMessage(data: WebSocket.RawData): Promise { - const message = JSON.parse(data.toString()) as WebsocketMessage; + const raw = data.toString(); + if (!raw.trim()) { + this.#log("warn", "appservice_websocket_empty_message"); + return; + } + let message: WebsocketMessage; + try { + message = JSON.parse(raw) as WebsocketMessage; + } catch (error: unknown) { + const messageText = error instanceof Error ? error.message : String(error); + this.#log("error", "appservice_websocket_invalid_json", { error: messageText, size: raw.length }); + return; + } this.#log("debug", "appservice_websocket_message", { command: message.command ?? "transaction", eventCount: message.events?.length, @@ -220,16 +229,6 @@ export class AppserviceWebsocket { if (message.command === "response" || message.command === "error") return; if (!message.command || message.command === "transaction") { await this.#handleTransaction?.(message as Record); - for (const raw of message.events ?? []) { - const event = rawMatrixEvent(raw); - this.#log("debug", "appservice_websocket_transaction_event", { - eventId: raw.event_id, - roomId: raw.room_id, - sender: raw.sender, - type: raw.type, - }); - if (event) await this.#dispatch(event); - } this.#send(messageResponse(message, true, { txn_id: message.txn_id })); return; } @@ -270,10 +269,6 @@ export class AppserviceWebsocket { txnId: transactionMatch[1], }); await this.#handleTransaction?.(transaction); - for (const raw of events) { - const event = rawMatrixEvent(raw as RawMatrixEvent); - if (event) await this.#dispatch(event); - } return jsonHTTPResponse(200, {}); } if (method === "GET" && /^\/?_matrix\/app\/v1\/users\//.test(path)) { @@ -324,7 +319,7 @@ interface WebsocketRequest { interface WebsocketMessage { command?: string; data?: unknown; - events?: RawMatrixEvent[]; + events?: unknown[]; id?: number; status?: string; to_device?: unknown; @@ -346,19 +341,6 @@ export interface HTTPProxyResponse { status: number; } -interface RawMatrixEvent { - [key: string]: unknown; - content?: Record; - event_id?: string; - origin_server_ts?: number; - redacts?: string; - room_id?: string; - sender?: string; - state_key?: string; - type?: string; - unsigned?: Record; -} - function messageResponse(message: WebsocketMessage, ok: boolean, data: unknown): WebsocketRequest | null { if (message.id === undefined || message.id === null || message.command === "response" || message.command === "error") return null; return { @@ -400,75 +382,6 @@ function eventCount(events: unknown): number | undefined { return Array.isArray(events) && events.length > 0 ? events.length : undefined; } -function rawMatrixEvent(raw: RawMatrixEvent): MatrixClientEvent | null { - const type = raw.type ?? ""; - const content = raw.content ?? {}; - const roomId = raw.room_id; - const eventId = raw.event_id; - const senderId = raw.sender; - const sender = senderId ? { isMe: false, userId: senderId } : undefined; - if (type === "m.room.message" && roomId && eventId && sender) { - return stripUndefined({ - attachments: [], - class: "message", - content, - edited: false, - encrypted: false, - eventId, - kind: "message", - messageType: stringValue(content.msgtype) ?? "m.text", - raw, - roomId, - sender, - text: stringValue(content.body) ?? "", - timestamp: raw.origin_server_ts, - type, - unsigned: raw.unsigned, - }) as MatrixClientEvent; - } - if (type === "m.reaction" && roomId && eventId && sender) { - const relates = objectValue(content["m.relates_to"]); - return stripUndefined({ - added: true, - class: "message", - content, - eventId, - key: stringValue(relates?.key) ?? "", - kind: "reaction", - raw, - relatesTo: stringValue(relates?.event_id) ?? "", - roomId, - sender, - timestamp: raw.origin_server_ts, - type, - unsigned: raw.unsigned, - }) as MatrixClientEvent; - } - if (type === "m.room.redaction" && roomId) { - return genericEvent("redaction", raw, content); - } - if (type === "m.typing") { - return genericEvent("typing", raw, content); - } - return genericEvent("raw", raw, content); -} - -function genericEvent(kind: "raw" | "redaction" | "typing", raw: RawMatrixEvent, content: Record): MatrixClientEvent { - const event = { - class: kind === "typing" ? "ephemeral" : "unknown", - content, - eventId: raw.event_id, - kind, - raw, - roomId: raw.room_id, - sender: raw.sender ? { isMe: false, userId: raw.sender } : undefined, - timestamp: raw.origin_server_ts, - type: raw.type ?? "", - unsigned: raw.unsigned, - }; - return stripUndefined(event) as MatrixClientEvent; -} - function objectValue(value: unknown): Record | undefined { return value && typeof value === "object" ? value as Record : undefined; } @@ -476,10 +389,3 @@ function objectValue(value: unknown): Record | undefined { function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } - -function stripUndefined>(value: T): T { - for (const key of Object.keys(value)) { - if (value[key] === undefined) delete value[key]; - } - return value; -} diff --git a/packages/bridge/src/beeper-stream.test.ts b/packages/bridge/src/beeper-stream.test.ts new file mode 100644 index 0000000..0090335 --- /dev/null +++ b/packages/bridge/src/beeper-stream.test.ts @@ -0,0 +1,292 @@ +import type { MatrixClient } from "@beeper/pickle"; +import { describe, expect, it, vi } from "vitest"; +import { BeeperTurnStream, runBeeperTurnStream } from "./beeper-stream"; + +describe("Beeper AI turn stream publisher", () => { + it("starts one ai-bridge backed stream and appends provider AG-UI events", async () => { + const { appendEvent, client, finish, start } = createClient(); + const publisher = new BeeperTurnStream({ + agentId: "codex", + agentName: "Codex", + client, + initialMessageMetadata: { agent_id: "codex" }, + model: "openclaw/plugin", + roomId: "!room:example.com", + turnId: "turn_1", + userId: "@sh-openclaw_agent_codex:example.com", + }); + + await publisher.publish({ messageId: "provider-msg", role: "assistant", type: "TEXT_MESSAGE_START" }); + await publisher.publish({ delta: "hello", messageId: "provider-msg", type: "TEXT_MESSAGE_CONTENT" }); + const result = await publisher.finalize(); + + expect(start).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledWith({ + agentId: "codex", + agentName: "Codex", + data: { agent_id: "codex" }, + initialEvents: [ + { messageId: "provider-msg", role: "assistant", type: "TEXT_MESSAGE_START" }, + ], + model: "openclaw/plugin", + roomId: "!room:example.com", + runId: "turn_1", + streamType: "com.beeper.llm", + threadId: "turn_1", + userId: "@sh-openclaw_agent_codex:example.com", + }); + expect(appendEvent.mock.calls.map(([options]) => options.event)).toEqual([ + { delta: "hello", messageId: "provider-msg", type: "TEXT_MESSAGE_CONTENT" }, + ]); + expect(finish).toHaveBeenCalledWith(expect.objectContaining({ + finishReason: "stop", + runId: "turn_1", + terminal: expect.objectContaining({ type: "RUN_FINISHED" }), + })); + expect(result).toEqual({ + eventId: "$target", + raw: { logicalEventId: "$target", raw: {}, replacementEventId: "$edit" }, + roomId: "!room:example.com", + }); + }); + + it("leaves message identity canonicalization to the native ai-bridge run", async () => { + const { appendEvent, client, start } = createClient(); + const publisher = new BeeperTurnStream({ + client, + roomId: "!room:example.com", + turnId: "turn_multi", + }); + + await publisher.publishMany([ + { messageId: "answer_1", role: "assistant", type: "TEXT_MESSAGE_START" }, + { delta: "first", messageId: "answer_1", type: "TEXT_MESSAGE_CONTENT" }, + { messageId: "answer_2", role: "assistant", type: "TEXT_MESSAGE_START" }, + { delta: "second", messageId: "answer_2", type: "TEXT_MESSAGE_CONTENT" }, + ]); + await publisher.finalize(); + + expect(start).toHaveBeenCalledWith(expect.objectContaining({ + initialEvents: [ + { messageId: "answer_1", role: "assistant", type: "TEXT_MESSAGE_START" }, + { delta: "first", messageId: "answer_1", type: "TEXT_MESSAGE_CONTENT" }, + { messageId: "answer_2", role: "assistant", type: "TEXT_MESSAGE_START" }, + { delta: "second", messageId: "answer_2", type: "TEXT_MESSAGE_CONTENT" }, + ], + })); + expect(appendEvent).not.toHaveBeenCalled(); + }); + + it("appends provider events after the native stream is started", async () => { + const { appendEvent, client, finish, start } = createClient(); + const publisher = new BeeperTurnStream({ + client, + roomId: "!room:example.com", + turnId: "turn_multi", + }); + + await publisher.start(); + await publisher.publishMany([ + { messageId: "answer_1", role: "assistant", type: "TEXT_MESSAGE_START" }, + { delta: "first", messageId: "answer_1", type: "TEXT_MESSAGE_CONTENT" }, + { messageId: "answer_2", role: "assistant", type: "TEXT_MESSAGE_START" }, + { delta: "second", messageId: "answer_2", type: "TEXT_MESSAGE_CONTENT" }, + ]); + await publisher.finalize(); + + expect(start).toHaveBeenCalledTimes(1); + expect(appendEvent.mock.calls.map(([options]) => [options.event.type, options.event.messageId, options.event.delta])).toEqual([ + ["TEXT_MESSAGE_START", "answer_1", undefined], + ["TEXT_MESSAGE_CONTENT", "answer_1", "first"], + ["TEXT_MESSAGE_START", "answer_2", undefined], + ["TEXT_MESSAGE_CONTENT", "answer_2", "second"], + ]); + expect(finish).toHaveBeenCalledTimes(1); + }); + + it("keeps tool result message ids separate from the assistant message", async () => { + const { appendEvent, client, start } = createClient(); + const publisher = new BeeperTurnStream({ + client, + roomId: "!room:example.com", + turnId: "turn_tool", + }); + + await publisher.publish({ + content: "{\"ok\":true}", + messageId: "tool_1", + role: "tool", + state: "complete", + toolCallId: "tool_1", + type: "TOOL_CALL_RESULT", + }); + + expect(appendEvent).not.toHaveBeenCalled(); + expect(start).toHaveBeenCalledWith(expect.objectContaining({ + initialEvents: [{ + content: "{\"ok\":true}", + messageId: "tool_1", + role: "tool", + state: "complete", + toolCallId: "tool_1", + type: "TOOL_CALL_RESULT", + }], + })); + }); + + it("publishes semantic parts through the native ai-bridge writer", async () => { + const { appendPart, client, start } = createClient(); + const publisher = new BeeperTurnStream({ + client, + roomId: "!room:example.com", + turnId: "turn_part", + }); + + await publisher.publishPart({ kind: "text", text: "hello" }); + await publisher.publishPart({ kind: "tool_result", output: { ok: true }, toolCallId: "tool_1", toolName: "search" }); + + expect(start).toHaveBeenCalledWith(expect.objectContaining({ + initialParts: [{ kind: "text", text: "hello" }], + })); + expect(appendPart.mock.calls.map(([options]) => options)).toEqual([ + { kind: "tool_result", output: { ok: true }, runId: "turn_part", toolCallId: "tool_1", toolName: "search" }, + ]); + }); + + it("finalizes run errors through the native stream", async () => { + const { client, error } = createClient(); + const publisher = new BeeperTurnStream({ + client, + roomId: "!room:example.com", + turnId: "turn_error", + }); + + await publisher.finalize({ + terminalPart: { + error: "tool exploded", + message: "Tool exploded", + runId: "turn_error", + type: "RUN_ERROR", + }, + }); + + expect(error).toHaveBeenCalledWith({ + message: "Tool exploded", + runId: "turn_error", + terminal: expect.objectContaining({ message: "Tool exploded", type: "RUN_ERROR" }), + type: "error", + }); + }); + + it("starts with subscribers, thread root, and ghost sender when provided", async () => { + const { client, start } = createClient(); + const publisher = new BeeperTurnStream({ + client, + roomId: "!room:example.com", + subscribers: [{ deviceId: "DEVICE", userId: "@alice:example.com" }], + threadRoot: "$root", + turnId: "turn_subscribed", + userId: "@agent:example.com", + }); + + await publisher.start(); + + expect(start).toHaveBeenCalledWith(expect.objectContaining({ + subscribers: [{ deviceId: "DEVICE", userId: "@alice:example.com" }], + threadRootEventId: "$root", + userId: "@agent:example.com", + })); + }); + + it("runs mapped provider events through one finalized turn stream", async () => { + const { appendEvent, client, finish, start } = createClient(); + const publisher = new BeeperTurnStream({ + client, + roomId: "!room:example.com", + turnId: "turn_runner", + }); + + await expect(runBeeperTurnStream({ + events: ["a", "b"], + mapEvent: (delta) => ({ delta, type: "TEXT_MESSAGE_CONTENT" }), + stream: publisher, + })).resolves.toMatchObject({ eventId: "$target" }); + + expect(start).toHaveBeenCalledWith(expect.objectContaining({ + initialEvents: [{ delta: "a", type: "TEXT_MESSAGE_CONTENT" }], + })); + expect(appendEvent.mock.calls.map(([options]) => options.event)).toEqual([ + { delta: "b", type: "TEXT_MESSAGE_CONTENT" }, + ]); + expect(finish).toHaveBeenCalledOnce(); + }); + + it("finalizes mapped provider failures as stream errors before rethrowing", async () => { + const { client, error, finish } = createClient(); + const publisher = new BeeperTurnStream({ + client, + roomId: "!room:example.com", + turnId: "turn_runner_error", + }); + + await expect(runBeeperTurnStream({ + events: ["a"], + mapEvent: () => { + throw new Error("provider exploded"); + }, + stream: publisher, + })).rejects.toThrow("provider exploded"); + + expect(error).toHaveBeenCalledWith(expect.objectContaining({ + message: "provider exploded", + runId: "turn_runner_error", + terminal: expect.objectContaining({ message: "provider exploded", type: "RUN_ERROR" }), + type: "error", + })); + expect(finish).not.toHaveBeenCalled(); + }); + +}); + +function createClient() { + const result = (runId: string, events: Record[] = []) => ({ + body: "...", + descriptor: { device_id: "DEVICE", type: "com.beeper.llm", user_id: "@bot:example.com" }, + eventId: "$target", + events, + finalAIMessage: {}, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: {}, + raw: {}, + replacementEventId: "$edit", + roomId: "!room:example.com", + runId, + threadId: runId, + }); + const start = vi.fn(async ({ runId }: { runId: string }) => + result(runId, [ + { runId, threadId: runId, type: "RUN_STARTED" }, + { messageId: `msg-${runId}`, role: "assistant", type: "TEXT_MESSAGE_START" }, + ])); + const appendEvent = vi.fn(async ({ event, runId }: { event: Record; runId: string }) => + result(runId, [event])); + const appendPart = vi.fn(async ({ runId }: { runId: string }) => + result(runId)); + const finish = vi.fn(async ({ finishReason, runId }: { finishReason?: string; runId: string }) => + result(runId, [{ finishReason: finishReason ?? "stop", runId, threadId: runId, type: "RUN_FINISHED" }])); + const error = vi.fn(async ({ message, runId }: { message?: string; runId: string }) => + result(runId, [{ message, runId, type: "RUN_ERROR" }])); + const client = { + beeper: { + aiRunStreams: { + appendEvent, + appendPart, + error, + finish, + start, + }, + }, + } as unknown as MatrixClient; + return { appendEvent, appendPart, client, error, finish, start }; +} diff --git a/packages/bridge/src/beeper-stream.ts b/packages/bridge/src/beeper-stream.ts new file mode 100644 index 0000000..537b2a3 --- /dev/null +++ b/packages/bridge/src/beeper-stream.ts @@ -0,0 +1,289 @@ +import type { MatrixBeeper, MatrixBeeperAIRunPartOptions, MatrixBeeperAIRunStreamResult, SentEvent } from "@beeper/pickle"; + +type AGUIEvent = Record & { type?: string }; +type BeeperTurnStreamPart = MatrixBeeperAIRunPartOptions; +type FinishReason = "stop" | "length" | "content_filter" | "tool_calls"; + +const BEEPER_AI_STREAM_TYPE = "com.beeper.llm"; + +const EVENT_RUN_ERROR = "RUN_ERROR"; +const EVENT_RUN_FINISHED = "RUN_FINISHED"; + +export interface BeeperTurnStreamClient { + beeper: MatrixBeeper; +} + +export interface BeeperStreamSubscriber { + deviceId: string; + userId: string; +} + +export interface CreateBeeperTurnStreamOptions { + agentId?: string; + agentName?: string; + client: BeeperTurnStreamClient; + initialMessageMetadata?: Record; + model?: string; + roomId: string; + subscribers?: BeeperStreamSubscriber[]; + threadRoot?: string; + turnId: string; + userId?: string; +} + +export interface BeeperStreamStartResult { + descriptor: Record; + eventId: string; + turnId: string; +} + +export interface BeeperStreamFinalizeOptions { + finishReason?: string; + terminalPart?: AGUIEvent; + usage?: unknown; +} + +export interface RunBeeperTurnStreamOptions { + events: Iterable | AsyncIterable; + finishReason?: string; + mapEvent: (event: T, stream: BeeperTurnStream) => Iterable | AGUIEvent | undefined | Promise | AGUIEvent | undefined>; + stream: BeeperTurnStream; +} + +export class BeeperTurnStream { + readonly roomId: string; + readonly turnId: string; + #agentId: string | undefined; + #agentName: string | undefined; + #client: BeeperTurnStreamClient; + #descriptor: Record | undefined; + #eventId: string | undefined; + #finalized = false; + #initialMessageMetadata: Record; + #model: string; + #queue = new SerialQueue(); + #started = false; + #subscribers: BeeperStreamSubscriber[]; + #threadRoot: string | undefined; + #userId: string | undefined; + + constructor(options: CreateBeeperTurnStreamOptions) { + this.#agentId = options.agentId; + this.#agentName = options.agentName; + this.#client = options.client; + this.#initialMessageMetadata = options.initialMessageMetadata ?? {}; + this.#model = options.model ?? "bridge/plugin"; + this.roomId = options.roomId; + this.turnId = options.turnId; + this.#subscribers = options.subscribers ?? []; + this.#threadRoot = options.threadRoot; + this.#userId = options.userId; + } + + get targetEventId(): string | undefined { + return this.#eventId; + } + + async start(): Promise { + return this.#queue.run(async () => { + const result = await this.#ensureStarted(); + return { descriptor: result.descriptor, eventId: result.eventId, turnId: this.turnId }; + }); + } + + async publish(event: AGUIEvent): Promise { + await this.publishMany([event]); + } + + async publishPart(part: BeeperTurnStreamPart): Promise { + await this.publishParts([part]); + } + + async publishParts(parts: Iterable): Promise { + return this.#queue.run(async () => { + if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); + const batch = [...parts].map((part) => stripUndefined({ ...part })); + if (batch.length === 0) return; + if (!this.#started) { + await this.#ensureStarted({ parts: batch }); + return; + } + await this.#ensureStarted(); + for (const part of batch) { + await this.#client.beeper.aiRunStreams.appendPart({ + ...part, + runId: this.turnId, + }); + } + }); + } + + async publishMany(events: Iterable): Promise { + return this.#queue.run(async () => { + if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); + const batch = [...events].map((event) => stripUndefined({ ...event })); + if (batch.length === 0) return; + if (!this.#started) { + await this.#ensureStarted({ events: batch }); + return; + } + await this.#ensureStarted(); + for (const event of batch) { + await this.#client.beeper.aiRunStreams.appendEvent({ + event, + runId: this.turnId, + }); + } + }); + } + + async finalize(options: BeeperStreamFinalizeOptions = {}): Promise { + return this.#queue.run(async () => { + if (this.#finalized) throw new Error("Beeper stream is already finalized"); + await this.#ensureStarted(); + const terminal = options.terminalPart ?? { + finishReason: normalizeFinishReason(options.finishReason), + runId: this.turnId, + threadId: this.turnId, + type: EVENT_RUN_FINISHED, + }; + const finishReason = normalizeFinishReason(stringValue(terminal.finishReason) ?? options.finishReason); + const result = terminal.type === EVENT_RUN_ERROR + ? await this.#client.beeper.aiRunStreams.error({ + message: terminalFallbackText(terminal), + runId: this.turnId, + terminal, + type: stringValue(terminal.terminalType) === "abort" ? "abort" : "error", + }) + : await this.#client.beeper.aiRunStreams.finish({ + finishReason, + runId: this.turnId, + terminal, + ...(options.usage !== undefined ? { usage: options.usage } : {}), + }); + this.#rememberStreamResult(result); + this.#finalized = true; + return { + eventId: result.eventId, + roomId: result.roomId, + raw: { + logicalEventId: result.eventId, + raw: result.raw, + replacementEventId: result.replacementEventId, + }, + }; + }); + } + + async #ensureStarted(initial?: { events?: AGUIEvent[]; parts?: BeeperTurnStreamPart[] }): Promise { + if (this.#started && this.#eventId) { + return { + descriptor: this.#descriptor ?? {}, + eventId: this.#eventId, + turnId: this.turnId, + }; + } + this.#started = true; + const result = await this.#client.beeper.aiRunStreams.start({ + ...(this.#agentId ? { agentId: this.#agentId } : {}), + ...(this.#agentName ? { agentName: this.#agentName } : {}), + data: this.#initialMessageMetadata, + ...(initial?.events?.length ? { initialEvents: initial.events } : {}), + ...(initial?.parts?.length ? { initialParts: initial.parts } : {}), + model: this.#model, + roomId: this.roomId, + runId: this.turnId, + streamType: BEEPER_AI_STREAM_TYPE, + ...(this.#subscribers.length > 0 ? { subscribers: this.#subscribers } : {}), + ...(this.#threadRoot ? { threadRootEventId: this.#threadRoot } : {}), + threadId: this.turnId, + ...(this.#userId ? { userId: this.#userId } : {}), + }); + this.#rememberStreamResult(result); + if (!this.#eventId) throw new Error("Beeper AI run stream did not return an event ID"); + return { + descriptor: this.#descriptor ?? {}, + eventId: this.#eventId, + turnId: this.turnId, + }; + } + + #rememberStreamResult(result: MatrixBeeperAIRunStreamResult): void { + this.#descriptor = recordValue(result.descriptor) ?? this.#descriptor; + this.#eventId = result.eventId || this.#eventId; + } +} + +export async function runBeeperTurnStream(options: RunBeeperTurnStreamOptions): Promise { + try { + for await (const event of toAsyncIterable(options.events)) { + const mapped = await options.mapEvent(event, options.stream); + const events = mapped === undefined ? [] : isAGUIEvent(mapped) ? [mapped] : [...mapped]; + if (events.length > 0) await options.stream.publishMany(events); + } + return await options.stream.finalize(options.finishReason === undefined ? {} : { finishReason: options.finishReason }); + } catch (error) { + await options.stream.finalize({ + terminalPart: { + error: { message: errorMessage(error) }, + message: errorMessage(error), + runId: options.stream.turnId, + threadId: options.stream.turnId, + type: EVENT_RUN_ERROR, + }, + }); + throw error; + } +} + +class SerialQueue { + #tail = Promise.resolve(); + + run(operation: () => Promise): Promise { + const next = this.#tail.then(operation, operation); + this.#tail = next.then(() => undefined, () => undefined); + return next; + } +} + +function terminalFallbackText(event: AGUIEvent | undefined): string { + if (!event) return ""; + if (event.type === EVENT_RUN_ERROR) { + return stringValue(event.message) ?? stringValue(event.error) ?? "Bridge run failed"; + } + return ""; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function recordValue(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function normalizeFinishReason(reason: string | undefined): FinishReason { + if (reason === "length" || reason === "content_filter" || reason === "tool_calls") return reason; + return "stop"; +} + +function stripUndefined>(record: T): T { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T; +} + +async function* toAsyncIterable(events: Iterable | AsyncIterable): AsyncIterable { + if (Symbol.asyncIterator in events) { + yield* events; + return; + } + yield* events; +} + +function isAGUIEvent(value: unknown): value is AGUIEvent { + return Boolean(recordValue(value)?.type); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/bridge/src/beeper.test.ts b/packages/bridge/src/beeper.test.ts index 9f0c440..8fa5ed5 100644 --- a/packages/bridge/src/beeper.test.ts +++ b/packages/bridge/src/beeper.test.ts @@ -50,7 +50,7 @@ describe("Beeper bridge manager helpers", () => { } expect(String(url)).toBe("https://api.example/bridgebox/alice/bridge/sh-dummy/bridge_state"); expect(init?.method).toBe("POST"); - expect(init?.headers).toMatchObject({ authorization: "Bearer token" }); + expect(init?.headers).toMatchObject({ authorization: "Bearer as" }); expect(JSON.parse(String(init?.body))).toEqual({ info: {}, isSelfHosted: true, @@ -110,6 +110,31 @@ describe("Beeper bridge manager helpers", () => { id: "sh-dummy", }); }); + + it("refuses to post bridge state without an appservice token", async () => { + const fetch = vi.fn(async (url: URL) => { + if (String(url) === "https://api.example/whoami") { + return jsonResponse({ + user: { bridges: {} }, + userInfo: { username: "alice" }, + }); + } + return jsonResponse({ + hs_token: "hs", + id: "sh-dummy", + namespaces: { user_ids: [{ exclusive: true, regex: "@dummy_.*:beeper.local" }] }, + sender_localpart: "dummybot", + url: "websocket", + }); + }); + + await expect(createBeeperAppServiceInit({ + baseDomain: "example", + bridge: "sh-dummy", + fetch: fetch as never, + token: "token", + })).rejects.toThrow("missing as_token"); + }); }); function jsonResponse(data: unknown): Response { diff --git a/packages/bridge/src/beeper.ts b/packages/bridge/src/beeper.ts index 467ea61..7bf456d 100644 --- a/packages/bridge/src/beeper.ts +++ b/packages/bridge/src/beeper.ts @@ -1,4 +1,9 @@ import type { MatrixAppserviceInitOptions, MatrixAppserviceNamespace, MatrixAppserviceRegistration } from "@beeper/pickle"; +export { loginWithMatrixPassword } from "@beeper/pickle/auth"; +export type { MatrixPasswordAuthOptions } from "@beeper/pickle/auth"; +export { createBeeperLogin } from "@beeper/pickle/beeper/auth"; +export type { BeeperAuthOptions, BeeperEnvironment } from "@beeper/pickle/beeper/auth"; +export type { MatrixAppserviceInitOptions, MatrixAppserviceRegistration, MatrixAppserviceSetProfileOptions } from "@beeper/pickle"; export interface BeeperClientOptions { baseDomain?: string; @@ -112,6 +117,9 @@ export class BeeperBridgeManagerClient { self_hosted: options.selfHosted ?? true, })); if (options.postState !== false) { + if (!registration.asToken) { + throw new Error(`Beeper appservice registration for ${options.bridge} did not include an appservice token`); + } const stateOptions: PostBridgeStateOptions = { bridge: options.bridge, isSelfHosted: options.selfHosted ?? true, @@ -119,12 +127,12 @@ export class BeeperBridgeManagerClient { stateEvent: bridgeStateEvent(options), }; if (options.bridgeType !== undefined) stateOptions.bridgeType = options.bridgeType; - await this.postBridgeState(stateOptions); + await this.postBridgeState(stateOptions, registration.asToken); } return registration; } - async postBridgeState(options: PostBridgeStateOptions): Promise { + async postBridgeState(options: PostBridgeStateOptions, token?: string): Promise { const whoami = await this.whoami(); const username = this.#username ?? whoami.userInfo.username; await this.#request("api", "POST", `/bridgebox/${encodeURIComponent(username)}/bridge/${encodeURIComponent(options.bridge)}/bridge_state`, { @@ -133,7 +141,7 @@ export class BeeperBridgeManagerClient { isSelfHosted: options.isSelfHosted ?? true, reason: options.reason, stateEvent: options.stateEvent, - }); + }, undefined, token); } async createAppService(options: CreateAppServiceOptions): Promise { diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index 21d692b..691ecf9 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -1,7 +1,7 @@ import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscription } from "@beeper/pickle"; import { describe, expect, it, vi } from "vitest"; import { RuntimeBridge } from "./bridge"; -import { createRemoteMessage } from "./events"; +import { createRemoteChatInfoChange, createRemoteMessage } from "./events"; import type { BridgeDataStore } from "./store"; import type { BridgeConnector, @@ -33,7 +33,7 @@ describe("RuntimeBridge", () => { expect(connector.init).toHaveBeenCalledOnce(); expect(connector.start).toHaveBeenCalledOnce(); expect(client.subscribe).toHaveBeenCalledWith( - { kind: ["message", "reaction", "redaction", "typing", "toDevice"] }, + { kind: ["message", "reaction", "redaction", "typing", "receipt", "accountData", "membership", "roomState", "toDevice"] }, expect.any(Function), { live: true } ); @@ -167,6 +167,91 @@ describe("RuntimeBridge", () => { expect(message.text).toBe("hello"); }); + it("dispatches Matrix edits to loaded network clients", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = createFakeConnector(network); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + + await bridge.start(); + await bridge.loadUserLogin(login); + bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey: { id: "remote-room", receiver: login.id } }); + + const result = await bridge.dispatchMatrixEvent({ + attachments: [], + class: "message", + content: { + body: "* corrected", + "m.new_content": { body: "corrected", msgtype: "m.text" }, + "m.relates_to": { event_id: "$old", rel_type: "m.replace" }, + msgtype: "m.text", + }, + edited: true, + encrypted: false, + eventId: "$edit", + kind: "message", + messageType: "m.text", + raw: {}, + replaces: "$old", + roomId: "!room:example", + sender: { isMe: false, userId: "@alice:example" }, + text: "corrected", + type: "m.room.message", + }); + + expect(result).toEqual({ dispatched: true, eventId: "$edit", handlers: 1, kind: "message", roomId: "!room:example" }); + expect(network.handleMatrixMessage).not.toHaveBeenCalled(); + expect(network.handleMatrixEdit).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + content: expect.objectContaining({ + "m.relates_to": { event_id: "$old", rel_type: "m.replace" }, + }), + portal: expect.objectContaining({ portalKey: { id: "remote-room", receiver: login.id } }), + targetMessage: { id: "$old" }, + text: "corrected", + }), + ); + }); + + it("dispatches Matrix reaction removals to loaded network clients", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = createFakeConnector(network); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + + await bridge.start(); + await bridge.loadUserLogin(login); + bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey: { id: "remote-room", receiver: login.id } }); + + const result = await bridge.dispatchMatrixEvent({ + added: false, + class: "message", + content: { "m.relates_to": { event_id: "$message", key: "👍", rel_type: "m.annotation" } }, + eventId: "$reaction", + key: "👍", + kind: "reaction", + raw: {}, + relatesTo: "$message", + roomId: "!room:example", + sender: { isMe: false, userId: "@alice:example" }, + type: "m.reaction", + }); + + expect(result).toEqual({ dispatched: true, eventId: "$reaction", handlers: 1, kind: "reaction", roomId: "!room:example" }); + expect(network.handleMatrixReaction).not.toHaveBeenCalled(); + expect(network.handleMatrixReactionRemove).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + portal: expect.objectContaining({ portalKey: { id: "remote-room", receiver: login.id } }), + targetMessage: { id: "$message" }, + targetReaction: { id: "$reaction" }, + }), + ); + }); + it("ignores Matrix messages from the bridge user", async () => { const client = createFakeMatrixClient(); const network = createFakeNetworkAPI(); @@ -207,6 +292,7 @@ describe("RuntimeBridge", () => { convert: () => ({ parts: [{ content: { body: "hello from remote", msgtype: "m.text" }, + extra: { "com.beeper.ai": { kind: "anchor", schema: "com.beeper.ai.v1" } }, type: "m.room.message", }], }), @@ -217,10 +303,396 @@ describe("RuntimeBridge", () => { })); await bridge.flushRemoteEvents(); - expect(client.raw.request).toHaveBeenCalledWith({ - body: { body: "hello from remote", msgtype: "m.text" }, - method: "PUT", - path: expect.stringContaining("/rooms/!room%3Aexample/send/m.room.message/pickle-bridge-"), + expect(client.messages.send).toHaveBeenCalledWith({ + content: { + body: "hello from remote", + "com.beeper.ai": { kind: "anchor", schema: "com.beeper.ai.v1" }, + msgtype: "m.text", + }, + messageType: "m.text", + roomId: "!room:example", + text: "hello from remote", + }); + }); + + it("handles queued remote edits, reactions, deletes, receipts, unread, and typing through Matrix transport", async () => { + const client = createFakeMatrixClient(); + const connector = createFakeConnector(createFakeNetworkAPI()); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + const portalKey = { id: "remote-room", receiver: login.id }; + + await bridge.start(); + bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey }); + bridge.queueRemoteEvent(login, createRemoteMessage({ + convert: () => ({ + parts: [{ + content: { body: "hello from remote", msgtype: "m.text" }, + type: "m.room.message", + }], + }), + data: {}, + id: "remote-message", + portalKey, + sender: { isFromMe: false, sender: "remote-user" }, + })); + await bridge.flushRemoteEvents(); + + bridge.queueRemoteEvent(login, { + convertEdit: async () => ({ + modifiedParts: [{ + content: { body: "edited remote", msgtype: "m.text" }, + extra: { "com.beeper.ai": { kind: "final", schema: "com.beeper.ai.v1" } }, + topLevelExtra: { "com.beeper.dont_render_edited": true }, + type: "m.room.message", + }], + }), + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "edit", + }); + bridge.queueRemoteEvent(login, { + getEmoji: () => "+1", + getID: () => "reaction-1", + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "reaction", + }); + bridge.queueRemoteEvent(login, { + getEmoji: () => "+1", + getID: () => "reaction-1", + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "reaction_remove", + }); + bridge.queueRemoteEvent(login, { + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "message_remove", + }); + bridge.queueRemoteEvent(login, { + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "read_receipt", + }); + bridge.queueRemoteEvent(login, { + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "delivery_receipt", + }); + bridge.queueRemoteEvent(login, { + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "mark_unread", + getUnread: () => true, + }); + bridge.queueRemoteEvent(login, { + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "mark_unread", + getUnread: () => false, + }); + bridge.queueRemoteEvent(login, { + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTimeoutMs: () => 5000, + getType: () => "typing", + isTyping: () => true, + }); + await bridge.flushRemoteEvents(); + + expect(client.messages.edit).toHaveBeenCalledWith({ + content: { + body: "edited remote", + "com.beeper.ai": { kind: "final", schema: "com.beeper.ai.v1" }, + msgtype: "m.text", + }, + eventId: "$sent", + roomId: "!room:example", + text: "edited remote", + topLevelContent: { "com.beeper.dont_render_edited": true }, + }); + expect(client.reactions.send).toHaveBeenCalledWith({ eventId: "$edit", key: "+1", roomId: "!room:example" }); + expect(client.reactions.redact).toHaveBeenCalledWith({ eventId: "$edit", key: "+1", roomId: "!room:example" }); + expect(client.messages.redact).toHaveBeenCalledWith({ eventId: "$edit", roomId: "!room:example" }); + expect(client.receipts.send).toHaveBeenCalledWith({ eventId: "$edit", receiptType: "m.read", roomId: "!room:example" }); + expect(client.receipts.send).toHaveBeenCalledWith({ eventId: "$edit", receiptType: "m.read.private", roomId: "!room:example" }); + expect(client.messages.markRead).toHaveBeenCalledWith({ eventId: "$edit", roomId: "!room:example" }); + expect(bridge.getPortal(portalKey)?.metadata).toMatchObject({ unread: false }); + expect(client.typing.set).toHaveBeenCalledWith({ roomId: "!room:example", timeoutMs: 5000, typing: true }); + }); + + it("applies remote chat info changes as bridge-owned Matrix room state", async () => { + const client = createFakeMatrixClient(); + const dataStore = createFakeBridgeDataStore(); + const connector = createFakeConnector(createFakeNetworkAPI()); + const bridge = new RuntimeBridge({ connector, dataStore, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + const portalKey = { id: "remote-room", receiver: login.id }; + + await bridge.start(); + bridge.registerPortal({ + avatar: { mxc: "mxc://example/old" }, + id: "remote-room", + mxid: "!room:example", + name: "Old", + portalKey, + topic: "Old topic", + }); + bridge.queueRemoteEvent(login, createRemoteChatInfoChange({ + chatInfoChange: { + chatInfo: { + avatar: { mxc: "mxc://example/new" }, + name: "New name", + topic: "New topic", + }, + }, + portalKey, + sender: { isFromMe: false, sender: "remote-user" }, + })); + await bridge.flushRemoteEvents(); + + expect(client.rooms.sendStateEvent).toHaveBeenCalledWith({ + content: { name: "New name" }, + eventType: "m.room.name", + roomId: "!room:example", + stateKey: "", + }); + expect(client.rooms.sendStateEvent).toHaveBeenCalledWith({ + content: { topic: "New topic" }, + eventType: "m.room.topic", + roomId: "!room:example", + stateKey: "", + }); + expect(client.rooms.sendStateEvent).toHaveBeenCalledWith({ + content: { url: "mxc://example/new" }, + eventType: "m.room.avatar", + roomId: "!room:example", + stateKey: "", + }); + expect(bridge.getPortal(portalKey)).toMatchObject({ + avatar: { mxc: "mxc://example/new" }, + name: "New name", + topic: "New topic", + }); + expect(dataStore.setPortal).toHaveBeenCalledWith(expect.objectContaining({ + avatar: { mxc: "mxc://example/new" }, + name: "New name", + topic: "New topic", + })); + }); + + it("exposes generic bridge-owned room state reads and writes", async () => { + const client = createFakeMatrixClient(); + const connector = createFakeConnector(createFakeNetworkAPI()); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + + await bridge.start(); + await expect(bridge.roomState.get({ + eventType: "com.example.state", + roomId: "!room:example", + })).resolves.toMatchObject({ + content: { ok: true }, + eventType: "com.example.state", + roomId: "!room:example", + stateKey: "", + }); + await bridge.roomState.set({ + content: { model: "beeper/openai/gpt-5.5" }, + eventType: "com.beeper.ai.model", + roomId: "!room:example", + }); + + expect(client.rooms.getStateEvent).toHaveBeenCalledWith({ + eventType: "com.example.state", + roomId: "!room:example", + stateKey: "", + }); + expect(client.rooms.sendStateEvent).toHaveBeenCalledWith({ + content: { model: "beeper/openai/gpt-5.5" }, + eventType: "com.beeper.ai.model", + roomId: "!room:example", + stateKey: "", + }); + }); + + it("updates bundled Matrix event targets through bridgev2 remote events", async () => { + const client = createFakeMatrixClient(); + const connector = createFakeConnector(createFakeNetworkAPI()); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + const portalKey = { id: "remote-room", receiver: login.id }; + + await bridge.start(); + bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey }); + bridge.queueRemoteEvent(login, { + convertEdit: async () => ({ + modifiedParts: [{ + content: { body: "stream final", msgtype: "m.text" }, + extra: { "com.beeper.ai": { kind: "final", schema: "com.beeper.ai.v1" } }, + type: "m.room.message", + }], + }), + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: true, sender: "@bot:example" }), + getTargetDBMessage: () => [{ id: "$stream", mxid: "$stream", partId: "0" }], + getTargetMessage: () => "$stream", + getType: () => "edit", + }); + bridge.queueRemoteEvent(login, { + getEmoji: () => "+1", + getID: () => "reaction-1", + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: true, sender: "@bot:example" }), + getTargetDBMessage: () => [{ id: "$stream", mxid: "$stream", partId: "0" }], + getTargetMessage: () => "$stream", + getType: () => "reaction", + }); + await bridge.flushRemoteEvents(); + + expect(client.messages.edit).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.objectContaining({ "com.beeper.ai": { kind: "final", schema: "com.beeper.ai.v1" } }), + eventId: "$stream", + roomId: "!room:example", + })); + expect(client.reactions.send).toHaveBeenCalledWith({ eventId: "$stream", key: "+1", roomId: "!room:example" }); + }); + + it("dispatches Matrix read receipts and marked-unread account data to network clients", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = createFakeConnector(network); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + const portalKey = { id: "remote-room", receiver: login.id }; + + await bridge.start(); + await bridge.loadUserLogin(login); + bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey }); + + await expect(bridge.dispatchMatrixEvent({ + class: "ephemeral", + content: { + "$event": { + "m.read": { + "@alice:example": { ts: 1 }, + "@bridge:example": { ts: 2 }, + }, + }, + }, + kind: "receipt", + raw: {}, + roomId: "!room:example", + type: "m.receipt", + } as MatrixClientEvent)).resolves.toEqual({ + dispatched: true, + handlers: 1, + kind: "receipt", + roomId: "!room:example", + }); + expect(network.handleMatrixReadReceipt).toHaveBeenCalledWith(expect.any(Object), { + portal: expect.objectContaining({ mxid: "!room:example", portalKey }), + receiptType: "m.read", + targetMessage: { id: "$event", mxid: "$event" }, + userId: "@alice:example", + }); + + await expect(bridge.dispatchMatrixEvent({ + class: "accountData", + content: { unread: true }, + kind: "accountData", + raw: {}, + roomId: "!room:example", + sender: { isMe: false, userId: "@alice:example" }, + type: "m.marked_unread", + } as MatrixClientEvent)).resolves.toEqual({ + dispatched: true, + handlers: 1, + kind: "accountData", + roomId: "!room:example", + }); + expect(network.handleMatrixMarkedUnread).toHaveBeenCalledWith(expect.any(Object), { + portal: expect.objectContaining({ mxid: "!room:example", portalKey }), + unread: true, + userId: "@alice:example", + }); + }); + + it("dispatches Matrix room metadata, membership, and delete-chat events to network clients", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = createFakeConnector(network); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + const portalKey = { id: "remote-room", receiver: login.id }; + + await bridge.start(); + await bridge.loadUserLogin(login); + bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey }); + + await expect(bridge.dispatchMatrixEvent(genericEvent({ + content: { name: "Project room" }, + kind: "roomState", + roomId: "!room:example", + type: "m.room.name", + }))).resolves.toMatchObject({ dispatched: true, handlers: 1, kind: "roomState", roomId: "!room:example" }); + expect(network.handleMatrixRoomName).toHaveBeenCalledWith(expect.any(Object), { + name: "Project room", + portal: expect.objectContaining({ mxid: "!room:example", portalKey }), + }); + + await bridge.dispatchMatrixEvent(genericEvent({ + content: { topic: "Planning" }, + kind: "roomState", + roomId: "!room:example", + type: "m.room.topic", + })); + expect(network.handleMatrixRoomTopic).toHaveBeenCalledWith(expect.any(Object), { + portal: expect.objectContaining({ mxid: "!room:example", portalKey }), + topic: "Planning", + }); + + await bridge.dispatchMatrixEvent(genericEvent({ + content: { url: "mxc://example/avatar" }, + kind: "roomState", + roomId: "!room:example", + type: "m.room.avatar", + })); + expect(network.handleMatrixRoomAvatar).toHaveBeenCalledWith(expect.any(Object), { + avatarUrl: "mxc://example/avatar", + portal: expect.objectContaining({ mxid: "!room:example", portalKey }), + }); + + await bridge.dispatchMatrixEvent(genericEvent({ + content: { membership: "invite" }, + kind: "membership", + roomId: "!room:example", + stateKey: "@bob:example", + type: "m.room.member", + })); + expect(network.handleMatrixMembership).toHaveBeenCalledWith(expect.any(Object), { + action: "invite", + portal: expect.objectContaining({ mxid: "!room:example", portalKey }), + userId: "@bob:example", + }); + + await bridge.dispatchMatrixEvent(genericEvent({ + content: { only_for_me: true }, + kind: "accountData", + roomId: "!room:example", + type: "com.beeper.delete_chat", + })); + expect(network.handleMatrixDeleteChat).toHaveBeenCalledWith(expect.any(Object), { + onlyForMe: true, + portal: expect.objectContaining({ mxid: "!room:example", portalKey }), }); }); @@ -246,6 +718,7 @@ describe("RuntimeBridge", () => { await bridge.start(); const portal = await bridge.createPortalRoom({ + creationContent: { "m.federate": false }, info: { name: "Remote room" }, portalKey: { id: "remote-room", receiver: "login:a" }, userId: "@test_alice:example", @@ -262,6 +735,7 @@ describe("RuntimeBridge", () => { expect(client.appservice.init).toHaveBeenCalledOnce(); expect(client.appservice.createPortalRoom).toHaveBeenCalledWith(expect.objectContaining({ bridge: expect.objectContaining({ networkId: "test" }), + creationContent: { "m.federate": false }, name: "Remote room", userId: "@test_alice:example", })); @@ -271,6 +745,78 @@ describe("RuntimeBridge", () => { expect(backfill.eventIds).toEqual(["$backfilled"]); }); + it("uses full Matrix user IDs directly for appservice portal senders", async () => { + const client = createFakeMatrixClient(); + const bridge = new RuntimeBridge({ + appservice: { + homeserver: "https://matrix.example", + homeserverDomain: "example", + registration: { + asToken: "as", + hsToken: "hs", + id: "test", + namespaces: { users: [{ exclusive: true, regex: "@test_.*:example" }] }, + senderLocalpart: "testbot", + url: "http://localhost:29300", + }, + }, + connector: createFakeConnector(createFakeNetworkAPI()), + matrix: matrixConfig(), + }, client); + + await bridge.start(); + await bridge.createPortal({ id: "remote-room", userId: "@owner:example" }, { + id: "remote-room", + roomType: "dm", + sender: "@test_agent_main:example", + }); + + expect(client.appservice.createPortalRoom).toHaveBeenCalledWith(expect.objectContaining({ + userId: "@test_agent_main:example", + })); + }); + + it("syncs appservice ghost profile when registering ghosts", async () => { + const client = createFakeMatrixClient(); + const bridge = new RuntimeBridge({ + appservice: { + homeserver: "https://matrix.example", + homeserverDomain: "example", + registration: { + asToken: "as", + hsToken: "hs", + id: "test", + namespaces: { users: [{ exclusive: true, regex: "@test_.*:example" }] }, + senderLocalpart: "testbot", + url: "http://localhost:29300", + }, + }, + connector: createFakeConnector(createFakeNetworkAPI()), + matrix: matrixConfig(), + }, client); + + await bridge.start(); + await bridge.registerGhost({ + avatar: { mxc: "mxc://example/agent" }, + displayName: "Agent Main", + id: "agent_main", + }); + + expect(bridge.getGhost("agent_main")).toEqual(expect.objectContaining({ + displayName: "Agent Main", + mxid: "@test_agent_main:example", + })); + expect(client.appservice.setProfile).toHaveBeenCalledWith(expect.objectContaining({ + avatarUrl: "mxc://example/agent", + displayName: "Agent Main", + isBridgeBot: false, + network: "test", + remoteId: "agent_main", + service: "test", + userId: "@test_agent_main:example", + })); + }); + it("adds Beeper room metadata and autojoin members for Beeper bridges", async () => { const client = createFakeMatrixClient(); const connector = createFakeConnector(createFakeNetworkAPI()); @@ -473,7 +1019,7 @@ describe("RuntimeBridge", () => { const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); await bridge.start(); - bridge.registerGhost({ displayName: "Alice", id: "alice", mxid: "@dummy_alice:example" }); + await bridge.registerGhost({ displayName: "Alice", id: "alice", mxid: "@dummy_alice:example" }); bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey: { id: "remote-room", receiver: "login:a" } }); const portal = await bridge.setPortalMetadata({ id: "remote-room", receiver: "login:a" }, { unread: true }); await bridge.setMessageRequest({ @@ -582,10 +1128,11 @@ describe("RuntimeBridge", () => { }) ); expect(network.handleMatrixMessage).not.toHaveBeenCalled(); - expect(client.raw.request).toHaveBeenCalledWith({ - body: { body: "pong", msgtype: "m.notice" }, - method: "PUT", - path: expect.stringContaining("/rooms/!created%3Aexample/send/m.room.message/pickle-bridge-"), + expect(client.messages.send).toHaveBeenCalledWith({ + content: { body: "pong", msgtype: "m.notice" }, + messageType: "m.notice", + roomId: "!created:example", + text: "pong", }); }); @@ -634,9 +1181,7 @@ describe("RuntimeBridge", () => { roomId: "!management:example", userId: "@testbot:example", }); - expect(client.raw.request).not.toHaveBeenCalledWith(expect.objectContaining({ - path: expect.stringContaining("/rooms/!management%3Aexample/send/m.room.message/"), - })); + expect(client.messages.send).not.toHaveBeenCalled(); }); it("handles built-in commands before connector command fallback", async () => { @@ -658,8 +1203,8 @@ describe("RuntimeBridge", () => { expect(result).toEqual({ dispatched: true, eventId: "$help", handlers: 1, kind: "message", roomId: "!management:example" }); expect(connector.handleCommand).not.toHaveBeenCalled(); - expect(client.raw.request).toHaveBeenCalledWith(expect.objectContaining({ - body: expect.objectContaining({ body: expect.stringContaining("Available commands:") }), + expect(client.messages.send).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.objectContaining({ body: expect.stringContaining("Available commands:") }), })); }); @@ -757,7 +1302,7 @@ describe("RuntimeBridge", () => { expect.anything(), expect.objectContaining({ portal }) ); - expect(client.raw.request).not.toHaveBeenCalled(); + expect(client.messages.send).not.toHaveBeenCalled(); }); it("promotes and persists management rooms through the built-in command", async () => { @@ -784,9 +1329,9 @@ describe("RuntimeBridge", () => { })); expect(dataStore.setManagementRoom).toHaveBeenCalledWith({ mxid: "!ordinary:example" }); - expect(client.raw.request).toHaveBeenCalledTimes(2); - expect(client.raw.request).toHaveBeenNthCalledWith(2, expect.objectContaining({ - body: expect.objectContaining({ body: expect.stringContaining("Available commands:") }), + expect(client.messages.send).toHaveBeenCalledTimes(2); + expect(client.messages.send).toHaveBeenNthCalledWith(2, expect.objectContaining({ + content: expect.objectContaining({ body: expect.stringContaining("Available commands:") }), })); }); @@ -923,14 +1468,36 @@ function createFakeConnector(network: FakeNetworkAPI): BridgeConnector & { type FakeNetworkAPI = NetworkAPI & { connect: ReturnType; disconnect: ReturnType; + handleMatrixEdit: ReturnType; + handleMatrixDeleteChat: ReturnType; + handleMatrixMarkedUnread: ReturnType; handleMatrixMessage: ReturnType; + handleMatrixMembership: ReturnType; + handleMatrixReaction: ReturnType; + handleMatrixReactionRemove: ReturnType; + handleMatrixReadReceipt: ReturnType; + handleMatrixRoomAvatar: ReturnType; + handleMatrixRoomName: ReturnType; + handleMatrixRoomTopic: ReturnType; + handleMatrixTyping: ReturnType; }; function createFakeNetworkAPI(): FakeNetworkAPI { return { connect: vi.fn(), disconnect: vi.fn(), + handleMatrixDeleteChat: vi.fn(), + handleMatrixEdit: vi.fn(), + handleMatrixMarkedUnread: vi.fn(), handleMatrixMessage: vi.fn(), + handleMatrixMembership: vi.fn(), + handleMatrixReaction: vi.fn(), + handleMatrixReactionRemove: vi.fn(), + handleMatrixReadReceipt: vi.fn(), + handleMatrixRoomAvatar: vi.fn(async () => true), + handleMatrixRoomName: vi.fn(async () => true), + handleMatrixRoomTopic: vi.fn(async () => true), + handleMatrixTyping: vi.fn(), }; } @@ -963,8 +1530,30 @@ function messageEvent(options: { body: string; eventId: string; roomId: string; }; } +function genericEvent(options: { + content: Record; + kind: "accountData" | "membership" | "roomState"; + roomId: string; + sender?: string; + stateKey?: string; + type: string; + unsigned?: Record; +}): MatrixClientEvent { + return { + class: options.kind === "accountData" ? "accountData" : "state", + content: options.content, + kind: options.kind, + raw: {}, + roomId: options.roomId, + ...(options.sender ? { sender: { isMe: false, userId: options.sender } } : {}), + ...(options.stateKey ? { stateKey: options.stateKey } : {}), + type: options.type, + ...(options.unsigned ? { unsigned: options.unsigned } : {}), + } as MatrixClientEvent; +} + function commandReplyBody(client: ReturnType, index: number): string { - return (client.raw.request as ReturnType).mock.calls[index]?.[0]?.body?.body; + return (client.messages.send as ReturnType).mock.calls[index]?.[0]?.content?.body; } function createFakeDataStore() { @@ -1011,6 +1600,7 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip ensureRegistered: vi.fn(async () => {}), init: vi.fn(async () => ({ botUserId: "@testbot:example", id: "test" })), sendMessage: vi.fn(async () => ({ eventId: "$sent", raw: {}, roomId: "!room:example" })), + setProfile: vi.fn(async () => {}), }, beeper: {} as MatrixClient["beeper"], boot: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@bridge:example" })), @@ -1025,26 +1615,47 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip uploadEncrypted: vi.fn(async () => ({ contentUri: "mxc://example/media", file: {} as never, raw: {} })), }, messages: { - edit: vi.fn(), + edit: vi.fn(async (options) => ({ eventId: "$edit", raw: {}, roomId: options.roomId })), get: vi.fn(), list: vi.fn(), markRead: vi.fn(), - redact: vi.fn(), - send: vi.fn(), + redact: vi.fn(async () => undefined), + send: vi.fn(async (options) => ({ eventId: "$sent", raw: {}, roomId: options.roomId })), sendMedia: vi.fn(async (options) => ({ eventId: "$media", raw: {}, roomId: options.roomId })), }, raw: { request: vi.fn(async () => ({ body: { event_id: "$sent" }, raw: { event_id: "$sent" }, status: 200 })), } as unknown as MatrixClient["raw"], - reactions: {} as MatrixClient["reactions"], - receipts: {} as MatrixClient["receipts"], - rooms: {} as MatrixClient["rooms"], + reactions: { + redact: vi.fn(async () => undefined), + send: vi.fn(async (options) => ({ eventId: "$reaction", raw: {}, roomId: options.roomId })), + }, + receipts: { + send: vi.fn(async () => undefined), + }, + rooms: { + getStateEvent: vi.fn(async (options) => ({ + content: { ok: true }, + eventId: "$state", + eventType: options.eventType, + raw: {}, + roomId: options.roomId, + stateKey: options.stateKey ?? "", + })), + sendStateEvent: vi.fn(async (options) => ({ + eventId: "$state-sent", + raw: {}, + roomId: options.roomId, + })), + } as unknown as MatrixClient["rooms"], streams: {} as MatrixClient["streams"], subscribe: vi.fn(async (_filter, _handler: (event: MatrixClientEvent) => void | Promise) => subscription), subscription, sync: {} as MatrixClient["sync"], toDevice: {} as MatrixClient["toDevice"], - typing: {} as MatrixClient["typing"], + typing: { + set: vi.fn(async () => undefined), + }, users: { get: vi.fn(async ({ userId }) => ({ avatarUrl: "mxc://example/alice", displayName: "Alice", raw: {}, userId })), getOwnAvatarUrl: vi.fn(async () => ({ avatarUrl: "mxc://example/me" })), diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index e7845aa..96f4d2d 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -1,15 +1,12 @@ -import { createMatrixClient } from "@beeper/pickle"; import type { MatrixAppserviceBatchSendOptions, MatrixAppserviceInitOptions, MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixReactionEvent, MatrixSubscription, SentEvent } from "@beeper/pickle"; import { AppserviceWebsocket, type HTTPProxyRequest, type HTTPProxyResponse } from "./appservice-websocket"; -import { createBeeperAppServiceInit } from "./beeper"; +import { BeeperTurnStream, type CreateBeeperTurnStreamOptions } from "./beeper-stream"; import { createRemoteMessage } from "./events"; -import { getOrCreateAppserviceDeviceId } from "./store"; import { handleProvisioningHTTPProxy } from "./provisioning"; import type { BridgeContext, BridgeLogger, BridgeRequestContext, - CreateBeeperBridgeOptions, CreateBridgeOptions, BridgeBackfillOptions, BridgeCreateManagementRoomOptions, @@ -31,19 +28,37 @@ import type { BridgeSendMediaOptions, BridgeState, BridgeStatus, + BridgeRoomStateAPI, + ChatInfo, + ChatInfoChange, DownloadMediaOptions, DownloadMediaResult, Ghost, MatrixDispatchResult, + MatrixEdit, MatrixMessage, MatrixReaction, + MatrixReactionRemove, MatrixRedaction, + MatrixReadReceipt, + MatrixMarkedUnread, MatrixTyping, + MatrixDeleteChat, + MatrixMembership, + MatrixRoomAvatar, + MatrixRoomName, + MatrixRoomTopic, + RoomAvatarHandlingNetworkAPI, + RoomNameHandlingNetworkAPI, + RoomTopicHandlingNetworkAPI, EventSender, MatrixIntent, MatrixCommand, MatrixCommandResponse, + ConvertedEdit, + ConvertedEditPart, ConvertedMessage, + ConvertedMessagePart, ManagementRoom, MessageRequest, MessageRequestHandlingNetworkAPI, @@ -52,6 +67,7 @@ import type { RemoteBackfill, RemoteChatDelete, RemoteChatInfoChange, + RemoteEdit, UserProfile, UserProfileUpdate, ResolveIdentifierParams, @@ -66,7 +82,6 @@ import type { BridgeStateEvent, BridgeStatePayload, BridgeBeeperOptions, - BridgeMatrixConfig, BridgeRemoteBackfillOptions, BridgeRemoteEventOptions, BridgeRemoteMessageOptions, @@ -78,79 +93,28 @@ import type { MessageCheckpointStep, HTTPProxyHandlingBridgeConnector, LoginStep, + Message, + RemoteDeliveryReceipt, + RemoteMarkUnread, + RemoteMessageRemove, + RemoteReadReceipt, + RemoteReaction, + RemoteReactionRemove, + RemoteTyping, + RemoteEventWithBundledParts, + RemoteEventWithTargetPart, } from "./types"; -type GenericMatrixEvent = Extract; kind: string }>; - -export function createBridge(options: CreateBridgeOptions): PickleBridge { - return new RuntimeBridge(options, createMatrixClient(options.matrix)); -} - -export async function createBeeperBridge(options: CreateBeeperBridgeOptions): Promise { - if (!options.store) throw new Error("createBeeperBridge requires store outside the Node entrypoint"); - const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit(beeperAppServiceOptions({ - address: options.address, - baseDomain: options.baseDomain, - bridge: options.bridge, - bridgeType: options.bridgeType, - getOnly: options.getOnly, - homeserverDomain: options.homeserverDomain, - token: options.account.accessToken, - })); - const matrix = { - ...options.matrix, - appservice: options.matrix?.appservice ?? appservice, - beeper: true, - deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(options.store, options.bridge), - homeserver: options.matrix?.homeserver ?? appservice.homeserver, - store: options.store, - token: options.matrix?.token ?? appservice.registration.asToken, - }; - return new RuntimeBridge(createBeeperRuntimeOptions(options, appservice, matrix), createMatrixClient(matrix)); -} - -export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOptions, client: MatrixClient): Promise { - const store = options.store ?? options.matrix?.store; - if (!store) throw new Error("createBeeperBridgeWithClient requires store"); - const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit(beeperAppServiceOptions({ - address: options.address, - baseDomain: options.baseDomain, - bridge: options.bridge, - bridgeType: options.bridgeType, - getOnly: options.getOnly, - homeserverDomain: options.homeserverDomain, - token: options.account.accessToken, - })); - const matrix = { - ...options.matrix, - appservice: options.matrix?.appservice ?? appservice, - beeper: true, - deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(store, options.bridge), - homeserver: options.matrix?.homeserver ?? appservice.homeserver, - store, - token: options.matrix?.token ?? appservice.registration.asToken, - }; - return new RuntimeBridge(createBeeperRuntimeOptions(options, appservice, matrix), client); -} - -function createBeeperRuntimeOptions(options: CreateBeeperBridgeOptions, appservice: NonNullable, matrix: BridgeMatrixConfig): CreateBridgeOptions { - const runtimeOptions: CreateBridgeOptions = { - appservice, - beeper: { - bridge: options.bridge, - ownerUserId: options.account.userId, - ...(options.bridgeType ? { bridgeType: options.bridgeType } : {}), - }, - connector: options.connector, - matrix, - }; - if (options.dataStore) runtimeOptions.dataStore = options.dataStore; - if (options.log) runtimeOptions.log = options.log; - return runtimeOptions; -} +type GenericMatrixEvent = Extract }> & { + kind: string; + sender?: { isMe?: boolean; userId?: string }; + stateKey?: string; + unsigned?: Record; +}; export class RuntimeBridge implements PickleBridge { readonly connector: CreateBridgeOptions["connector"]; + readonly roomState: BridgeRoomStateAPI; readonly #appserviceOptions: CreateBridgeOptions["appservice"]; readonly #beeperOptions: BridgeBeeperOptions | undefined; readonly #dataStore: CreateBridgeOptions["dataStore"]; @@ -184,6 +148,23 @@ export class RuntimeBridge implements PickleBridge { this.#dataStore = options.dataStore; this.#log = options.log ?? defaultLogger; this.#matrixClient = client; + this.roomState = { + get: (state) => this.#matrixClient.rooms.getStateEvent({ + eventType: state.eventType, + roomId: state.roomId, + stateKey: state.stateKey ?? "", + }), + set: (state) => { + const roomId = state.roomId ?? state.portal?.mxid; + if (!roomId) throw new Error("Room state writes require a room id or a portal with a Matrix room"); + return this.#matrixClient.rooms.sendStateEvent({ + content: state.content, + eventType: state.eventType, + roomId, + stateKey: state.stateKey ?? "", + }); + }, + }; } get client(): MatrixClient | null { @@ -278,6 +259,13 @@ export class RuntimeBridge implements PickleBridge { return room; } + createBeeperTurnStream(options: Omit): BeeperTurnStream { + return new BeeperTurnStream({ + ...options, + client: this.#matrixClient, + }); + } + async createPortalRoom(options: BridgeCreatePortalRoomOptions): Promise { this.#requestContext(); const invite = autoJoinInvite(options.invite, this.#beeperOptions?.ownerUserId); @@ -289,6 +277,7 @@ export class RuntimeBridge implements PickleBridge { avatarUrl: info.avatar?.mxc ?? options.avatarUrl, bridge: this.connector.getName(), bridgeName: this.#beeperOptions?.bridge, + creationContent: options.creationContent, initialState: options.initialState, initialMembers: this.#beeperOptions ? invite : undefined, invite, @@ -301,11 +290,15 @@ export class RuntimeBridge implements PickleBridge { userId: options.userId, })); const portal: Portal = { + ...(info.avatar ? { avatar: info.avatar } : {}), id: options.portalKey.id, metadata: options.metadata, mxid: result.roomId, + ...(name ? { name } : {}), portalKey: options.portalKey, ...(options.portalKey.receiver ? { receiver: options.portalKey.receiver } : {}), + ...(options.roomType ? { roomType: options.roomType } : {}), + ...(topic ? { topic } : {}), }; this.registerPortal(portal); return portal; @@ -316,7 +309,7 @@ export class RuntimeBridge implements PickleBridge { return this.createPortalRoom({ ...roomOptions, portalKey: { id, receiver: login.id }, - ...(sender ? { userId: this.ghostUserId(sender) } : {}), + ...(sender ? { userId: this.senderUserId(sender) } : {}), }); } @@ -511,11 +504,43 @@ export class RuntimeBridge implements PickleBridge { return `@${escaped}:${domainFromUserID(this.#ownerUserId ?? this.#ownUserId ?? "@bridge:example")}`; } - registerGhost(ghost: Ghost): void { - this.#ghosts.set(ghost.id, ghost); - void this.#dataStore?.setGhost(ghost).catch((error: unknown) => { + senderUserId(sender: string): string { + return sender.startsWith("@") ? sender : this.ghostUserId(sender); + } + + async registerGhost(ghost: Ghost): Promise { + const registeredGhost = { + ...ghost, + mxid: ghost.mxid ?? this.ghostUserId(ghost.id), + }; + this.#ghosts.set(registeredGhost.id, registeredGhost); + await this.#dataStore?.setGhost(registeredGhost).catch((error: unknown) => { this.#log("warn", "ghost_store_failed", { error }); }); + await this.#syncGhostProfile(registeredGhost); + } + + async #syncGhostProfile(ghost: Ghost): Promise { + if (!this.#appserviceOptions) return; + const userId = ghost.mxid ?? this.ghostUserId(ghost.id); + const bridgeName = this.connector.getName(); + try { + await this.#matrixClient.appservice.setProfile(stripUndefined({ + avatarUrl: ghostAvatarURL(ghost), + displayName: ghost.displayName, + extra: ghost.profile, + identifiers: ghost.identifiers, + isBridgeBot: false, + isNetworkBot: ghost.isBot, + network: bridgeName.networkId, + remoteId: ghost.id, + service: bridgeName.beeperBridgeType ?? bridgeName.networkId, + userId, + })); + this.#log("info", "ghost_profile_synced", { displayName: ghost.displayName, ghostId: ghost.id, userId }); + } catch (error: unknown) { + this.#log("warn", "ghost_profile_sync_failed", { error, ghostId: ghost.id, userId }); + } } getPortal(portalKey: { id: string; receiver?: string }): Portal | null { @@ -537,7 +562,7 @@ export class RuntimeBridge implements PickleBridge { } #eventSenderReference(login: UserLogin, sender: string | EventSender): EventSender { - return typeof sender === "string" ? { isFromMe: false, sender: this.ghostUserId(sender), senderLogin: login.id } : sender; + return typeof sender === "string" ? { isFromMe: false, sender: this.senderUserId(sender), senderLogin: login.id } : sender; } getPortalByMXID(mxid: string): Portal | null { @@ -670,9 +695,11 @@ export class RuntimeBridge implements PickleBridge { sender: "sender" in event ? event.sender.userId : undefined, }); if (event.kind === "message") { + if (isMatrixEditEvent(event)) return this.#dispatchMatrixEdit(event); return this.#dispatchMatrixMessage(event); } if (event.kind === "reaction") { + if (event.added === false) return this.#dispatchMatrixReactionRemove(event); return this.#dispatchMatrixReaction(event); } if (isGenericEvent(event, "redaction")) { @@ -681,6 +708,27 @@ export class RuntimeBridge implements PickleBridge { if (isGenericEvent(event, "typing")) { return this.#dispatchMatrixTyping(event); } + if (isGenericEvent(event, "receipt")) { + return this.#dispatchMatrixReceipt(event); + } + if (isMatrixMarkedUnreadEvent(event)) { + return this.#dispatchMatrixMarkedUnread(event); + } + if (isMatrixRoomNameEvent(event)) { + return this.#dispatchMatrixRoomName(event); + } + if (isMatrixRoomTopicEvent(event)) { + return this.#dispatchMatrixRoomTopic(event); + } + if (isMatrixRoomAvatarEvent(event)) { + return this.#dispatchMatrixRoomAvatar(event); + } + if (isMatrixMembershipEvent(event)) { + return this.#dispatchMatrixMembership(event); + } + if (isMatrixDeleteChatEvent(event)) { + return this.#dispatchMatrixDeleteChat(event); + } return { dispatched: false, handlers: 0, kind: event.kind }; } @@ -757,11 +805,11 @@ export class RuntimeBridge implements PickleBridge { async #subscribeMatrixEvents(): Promise { const subscription = await this.#matrixClient.subscribe( - { kind: ["message", "reaction", "redaction", "typing", "toDevice"] }, + { kind: ["message", "reaction", "redaction", "typing", "receipt", "accountData", "membership", "roomState", "toDevice"] }, (event) => { if (this.#traceToDeviceEvent(event)) return; void this.dispatchMatrixEvent(event).catch((error: unknown) => { - this.#log("error", "matrix_dispatch_failed", { error }); + this.#log("error", "matrix_dispatch_failed", { error: errorMessage(error) }); }); }, { live: true } @@ -799,7 +847,6 @@ export class RuntimeBridge implements PickleBridge { this.#log("info", "appservice_websocket_starting", { homeserver: this.#appserviceOptions.homeserver }); this.#appserviceWebsocket = new AppserviceWebsocket({ appservice: this.#appserviceOptions, - dispatch: (event) => this.dispatchMatrixEvent(event), handleHTTPProxy: (request) => this.#handleHTTPProxy(request), handleTransaction: (transaction) => this.#handleAppserviceTransaction(transaction), log: this.#log, @@ -847,8 +894,26 @@ export class RuntimeBridge implements PickleBridge { listLogins: () => Array.from(this.#userLogins.values()), loginFlows: () => this.connector.getLoginFlows(), loadLogin: (login) => this.loadUserLogin(login).then(() => undefined), + backfill: (login, roomId, params) => this.queueBackfill(login, { + ...params, + portal: this.#portalForRoom(roomId), + }), + listContacts: async (login) => { + const client = await this.loadUserLogin(login); + if (!hasMethod(client, "listContacts")) { + throw new Error(`Login ${login.id} does not support contact listing`); + } + return (client as import("./types").ContactListingNetworkAPI).listContacts(this.#requestContext(), {}); + }, requestContext: () => this.#requestContext(), resolveIdentifier: (login, identifier, createDM) => this.resolveIdentifier(login, { createDM, identifier }), + searchUsers: async (login, query) => { + const client = await this.loadUserLogin(login); + if (!hasMethod(client, "searchUsers")) { + throw new Error(`Login ${login.id} does not support user search`); + } + return (client as import("./types").UserSearchingNetworkAPI).searchUsers(this.#requestContext(), { query }); + }, }, { logins: this.#provisioningLogins }, request); } @@ -894,6 +959,42 @@ export class RuntimeBridge implements PickleBridge { return { dispatched: handlers > 0, eventId: event.eventId, handlers, kind: event.kind, roomId: event.roomId }; } + async #dispatchMatrixEdit(event: MatrixMessageEvent): Promise { + if (event.sender.isMe || event.sender.userId === this.#ownUserId) { + this.#log("debug", "matrix_edit_ignored_own", { eventId: event.eventId, roomId: event.roomId, sender: event.sender.userId }); + return { dispatched: false, eventId: event.eventId, handlers: 0, kind: event.kind, roomId: event.roomId }; + } + const targetEventId = matrixEditTargetEventId(event); + if (!targetEventId) return this.#dispatchMatrixMessage(event); + const portal = this.#portalForRoom(event.roomId); + const msg: MatrixEdit = { + attachments: event.attachments, + content: event.content, + event, + existing: [], + portal, + sender: event.sender, + targetMessage: { id: targetEventId }, + text: event.text, + ...(event.replyTo ? { replyTo: { id: event.replyTo } } : {}), + ...(event.threadRoot ? { threadRoot: { id: event.threadRoot } } : {}), + }; + let handlers = 0; + try { + for (const client of this.#networkClientsForPortal(portal)) { + if (!hasMethod(client, "handleMatrixEdit")) continue; + handlers += 1; + this.#log("debug", "matrix_edit_to_network", { eventId: event.eventId, loginHandlers: handlers, roomId: event.roomId, targetEventId }); + await client.handleMatrixEdit(this.#requestContext(), msg); + } + this.#sendMatrixEventCheckpoint(event, "BRIDGE", handlers > 0 ? "SUCCESS" : "UNSUPPORTED"); + } catch (error: unknown) { + this.#sendMatrixEventCheckpoint(event, "BRIDGE", "PERM_FAILURE", errorMessage(error)); + throw error; + } + return { dispatched: handlers > 0, eventId: event.eventId, handlers, kind: event.kind, roomId: event.roomId }; + } + async #dispatchMatrixCommand(command: MatrixCommand): Promise { const builtinResponse = await this.#handleBuiltinCommand(command); if (builtinResponse) { @@ -1091,6 +1192,27 @@ export class RuntimeBridge implements PickleBridge { return { dispatched: handlers > 0, eventId: event.eventId, handlers, kind: event.kind, roomId: event.roomId }; } + async #dispatchMatrixReactionRemove(event: MatrixReactionEvent): Promise { + if (event.sender.isMe || event.sender.userId === this.#ownUserId) { + return { dispatched: false, eventId: event.eventId, handlers: 0, kind: event.kind, roomId: event.roomId }; + } + const portal = this.#portalForRoom(event.roomId); + const msg: MatrixReactionRemove = { + content: event.content, + event, + portal, + targetMessage: { id: event.relatesTo }, + targetReaction: { id: event.eventId }, + }; + let handlers = 0; + for (const client of this.#networkClientsForPortal(portal)) { + if (!hasMethod(client, "handleMatrixReactionRemove")) continue; + handlers += 1; + await client.handleMatrixReactionRemove(this.#requestContext(), msg); + } + return { dispatched: handlers > 0, eventId: event.eventId, handlers, kind: event.kind, roomId: event.roomId }; + } + async #dispatchMatrixRedaction(event: GenericMatrixEvent): Promise { const roomId = event.roomId; if (!roomId || !event.eventId) { @@ -1101,6 +1223,7 @@ export class RuntimeBridge implements PickleBridge { const msg: MatrixRedaction = { eventId: event.eventId, portal: this.#portalForRoom(roomId), + ...(matrixRedactionTargetEventId(event) ? { targetMessage: { id: matrixRedactionTargetEventId(event)! } } : {}), }; let handlers = 0; for (const client of this.#networkClientsForPortal(msg.portal)) { @@ -1138,6 +1261,164 @@ export class RuntimeBridge implements PickleBridge { return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; } + async #dispatchMatrixReceipt(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + if (!roomId) { + return { dispatched: false, handlers: 0, kind: event.kind }; + } + const portal = this.#portalForRoom(roomId); + const receipts = matrixReadReceipts(event.content); + let handlers = 0; + for (const receipt of receipts) { + if (receipt.userId === this.#ownUserId) continue; + const msg: MatrixReadReceipt = { + portal, + receiptType: receipt.receiptType, + targetMessage: { id: receipt.eventId, mxid: receipt.eventId }, + userId: receipt.userId, + }; + for (const client of this.#networkClientsForPortal(portal)) { + if (!hasMethod(client, "handleMatrixReadReceipt")) continue; + handlers += 1; + await client.handleMatrixReadReceipt(this.#requestContext(), msg); + } + } + return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; + } + + async #dispatchMatrixMarkedUnread(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + if (!roomId) { + return { dispatched: false, handlers: 0, kind: event.kind }; + } + const unread = booleanValue(event.content.unread ?? event.content.marked_unread ?? event.content.markedUnread); + if (unread === undefined) { + return { dispatched: false, handlers: 0, kind: event.kind, roomId }; + } + const portal = this.#portalForRoom(roomId); + const msg: MatrixMarkedUnread = { + portal, + unread, + ...(event.sender?.userId ? { userId: event.sender.userId } : {}), + }; + let handlers = 0; + for (const client of this.#networkClientsForPortal(portal)) { + if (!hasMethod(client, "handleMatrixMarkedUnread")) continue; + handlers += 1; + await client.handleMatrixMarkedUnread(this.#requestContext(), msg); + } + return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; + } + + async #dispatchMatrixRoomName(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + if (!roomId) return { dispatched: false, handlers: 0, kind: event.kind }; + if (event.sender?.isMe || event.sender?.userId === this.#ownUserId) { + return { dispatched: false, handlers: 0, kind: event.kind, roomId }; + } + const name = stringValue(event.content.name); + const portal = this.#portalForRoom(roomId); + if (portal.name === name) return { dispatched: false, handlers: 0, kind: event.kind, roomId }; + const msg: MatrixRoomName = stripUndefined({ + name, + portal, + }); + let handlers = 0; + let accepted = false; + for (const client of this.#networkClientsForPortal(msg.portal)) { + if (!hasMethod(client, "handleMatrixRoomName")) continue; + handlers += 1; + accepted = await (client as RoomNameHandlingNetworkAPI).handleMatrixRoomName(this.#requestContext(), msg) || accepted; + } + if (accepted && name !== undefined) await this.#updatePortalInfo(portal, { name }); + return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; + } + + async #dispatchMatrixRoomTopic(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + if (!roomId) return { dispatched: false, handlers: 0, kind: event.kind }; + if (event.sender?.isMe || event.sender?.userId === this.#ownUserId) { + return { dispatched: false, handlers: 0, kind: event.kind, roomId }; + } + const topic = stringValue(event.content.topic); + const portal = this.#portalForRoom(roomId); + if (portal.topic === topic) return { dispatched: false, handlers: 0, kind: event.kind, roomId }; + const msg: MatrixRoomTopic = stripUndefined({ + portal, + topic, + }); + let handlers = 0; + let accepted = false; + for (const client of this.#networkClientsForPortal(msg.portal)) { + if (!hasMethod(client, "handleMatrixRoomTopic")) continue; + handlers += 1; + accepted = await (client as RoomTopicHandlingNetworkAPI).handleMatrixRoomTopic(this.#requestContext(), msg) || accepted; + } + if (accepted && topic !== undefined) await this.#updatePortalInfo(portal, { topic }); + return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; + } + + async #dispatchMatrixRoomAvatar(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + if (!roomId) return { dispatched: false, handlers: 0, kind: event.kind }; + if (event.sender?.isMe || event.sender?.userId === this.#ownUserId) { + return { dispatched: false, handlers: 0, kind: event.kind, roomId }; + } + const avatarUrl = stringValue(event.content.url); + const portal = this.#portalForRoom(roomId); + if ((portal.avatar?.mxc ?? portal.avatar?.url) === avatarUrl) return { dispatched: false, handlers: 0, kind: event.kind, roomId }; + const msg: MatrixRoomAvatar = stripUndefined({ + avatarUrl, + portal, + }); + let handlers = 0; + let accepted = false; + for (const client of this.#networkClientsForPortal(msg.portal)) { + if (!hasMethod(client, "handleMatrixRoomAvatar")) continue; + handlers += 1; + accepted = await (client as RoomAvatarHandlingNetworkAPI).handleMatrixRoomAvatar(this.#requestContext(), msg) || accepted; + } + if (accepted) await this.#updatePortalInfo(portal, { avatar: avatarUrl ? { mxc: avatarUrl } : { remove: true } }); + return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; + } + + async #dispatchMatrixMembership(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + const userId = event.stateKey; + const action = matrixMembershipAction(event); + if (!roomId || !userId || !action) { + return roomId ? { dispatched: false, handlers: 0, kind: event.kind, roomId } : { dispatched: false, handlers: 0, kind: event.kind }; + } + const msg: MatrixMembership = { + action, + portal: this.#portalForRoom(roomId), + userId, + }; + let handlers = 0; + for (const client of this.#networkClientsForPortal(msg.portal)) { + if (!hasMethod(client, "handleMatrixMembership")) continue; + handlers += 1; + await client.handleMatrixMembership(this.#requestContext(), msg); + } + return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; + } + + async #dispatchMatrixDeleteChat(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + if (!roomId) return { dispatched: false, handlers: 0, kind: event.kind }; + const msg: MatrixDeleteChat = stripUndefined({ + onlyForMe: booleanValue(event.content.only_for_me ?? event.content.onlyForMe), + portal: this.#portalForRoom(roomId), + }); + let handlers = 0; + for (const client of this.#networkClientsForPortal(msg.portal)) { + if (!hasMethod(client, "handleMatrixDeleteChat")) continue; + handlers += 1; + await client.handleMatrixDeleteChat(this.#requestContext(), msg); + } + return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; + } + #portalForRoom(roomId: string): Portal { const existing = this.#portalsByRoom.get(roomId); if (existing) return existing; @@ -1185,6 +1466,38 @@ export class RuntimeBridge implements PickleBridge { await this.#handleRemoteMessage(event as RemoteMessage); return; } + if (type === "edit") { + await this.#handleRemoteEdit(event as RemoteEdit); + return; + } + if (type === "reaction") { + await this.#handleRemoteReaction(event as RemoteReaction); + return; + } + if (type === "reaction_remove") { + await this.#handleRemoteReactionRemove(event as RemoteReactionRemove); + return; + } + if (type === "message_remove") { + await this.#handleRemoteMessageRemove(event as RemoteMessageRemove); + return; + } + if (type === "read_receipt") { + await this.#handleRemoteReadReceipt(event as RemoteReadReceipt); + return; + } + if (type === "delivery_receipt") { + await this.#handleRemoteDeliveryReceipt(event as RemoteDeliveryReceipt); + return; + } + if (type === "mark_unread") { + await this.#handleRemoteMarkUnread(event as RemoteMarkUnread); + return; + } + if (type === "typing") { + await this.#handleRemoteTyping(event as RemoteTyping); + return; + } if (type === "backfill") { await this.#handleRemoteBackfill(event as RemoteBackfill); return; @@ -1208,7 +1521,7 @@ export class RuntimeBridge implements PickleBridge { const converted = await event.convertMessage(this.#requestContext(), portal, this.#matrixIntent()); for (const [index, part] of converted.parts.entries()) { const sender = event.getSender(); - const sent = await this.#sendRemoteMessagePart(portal.mxid, sender.sender, part.content, eventTimestamp(event)); + const sent = await this.#sendRemoteMessagePart(portal.mxid, sender.sender, convertedPartContent(part), eventTimestamp(event)); const messageKey = messagePartKey(event.getID(), part.id ?? String(index)); const message = { eventId: sent.eventId, @@ -1230,15 +1543,243 @@ export class RuntimeBridge implements PickleBridge { await this.backfill({ events, roomId: portal.mxid }); } + async #handleRemoteEdit(event: RemoteEdit): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + const existing = await this.#remoteTargetMessages(event); + if (existing.sent.length === 0) { + throw new Error(`No Matrix message stored for remote edit target ${event.getTargetMessage()}`); + } + const converted = await event.convertEdit(this.#requestContext(), portal, this.#matrixIntent(), existing.db); + const matrixPortal = portal as Portal & { mxid: string }; + await this.#sendConvertedEdit(event, matrixPortal, converted, existing); + } + + async #sendConvertedEdit( + event: RemoteEdit, + portal: Portal & { mxid: string }, + converted: ConvertedEdit, + existing: { db: Message[]; sent: SentEvent[] } + ): Promise { + for (const [index, part] of converted.modifiedParts.entries()) { + const target = this.#convertedEditPartTarget(part, existing, index); + if (!target?.eventId) continue; + let sent = target; + if (!part.dontBridge) { + sent = await this.#matrixClient.messages.edit({ + content: convertedPartContent(part), + eventId: target.eventId, + roomId: portal.mxid ?? target.roomId, + text: stringValue(part.content.body) ?? "", + ...(part.topLevelExtra ? { topLevelContent: part.topLevelExtra } : {}), + }); + } + const messageKey = messagePartKey(event.getTargetMessage(), part.id ?? part.part?.partId ?? String(index)); + const message = { + eventId: sent.eventId, + raw: sent.raw, + roomId: sent.roomId || portal.mxid, + }; + this.#messages.set(messageKey, message); + await this.#dataStore?.setMessage(messageKey, message); + } + for (const part of converted.deletedParts ?? []) { + if (!part.mxid) continue; + await this.#matrixClient.messages.redact({ + eventId: part.mxid, + roomId: portal.mxid, + }); + } + if (converted.addedParts) { + for (const [index, part] of converted.addedParts.parts.entries()) { + const sender = event.getSender(); + const sent = await this.#sendRemoteMessagePart(portal.mxid, sender.sender, convertedPartContent(part), eventTimestamp(event)); + const messageKey = messagePartKey(event.getTargetMessage(), part.id ?? `added-${index}`); + const message = { + eventId: sent.eventId, + raw: sent.raw, + roomId: sent.roomId, + }; + this.#messages.set(messageKey, message); + await this.#dataStore?.setMessage(messageKey, message); + } + } + } + + async #handleRemoteReaction(event: RemoteReaction): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + const target = await this.#remoteTargetMessage(event); + if (!target?.eventId) { + throw new Error(`No Matrix message stored for remote reaction target ${event.getTargetMessage()}`); + } + await this.#matrixClient.reactions.send({ + eventId: target.eventId, + key: event.getEmoji(), + roomId: portal.mxid, + }); + } + + async #handleRemoteReactionRemove(event: RemoteReactionRemove): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + const target = await this.#remoteTargetMessage(event); + if (!target?.eventId) { + throw new Error(`No Matrix message stored for remote reaction remove target ${event.getTargetMessage()}`); + } + const emoji = event.getEmoji?.(); + if (!emoji) return; + await this.#matrixClient.reactions.redact({ + eventId: target.eventId, + key: emoji, + roomId: portal.mxid, + }); + } + + async #handleRemoteMessageRemove(event: RemoteMessageRemove): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + for (const target of (await this.#remoteTargetMessages(event)).sent) { + if (!target.eventId) continue; + await this.#matrixClient.messages.redact({ + eventId: target.eventId, + roomId: portal.mxid, + }); + } + } + + async #handleRemoteReadReceipt(event: RemoteReadReceipt): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + const target = await this.#remoteTargetMessage(event); + if (!target?.eventId) { + throw new Error(`No Matrix message stored for remote read receipt target ${event.getTargetMessage()}`); + } + await this.#matrixClient.receipts.send({ + eventId: target.eventId, + receiptType: "m.read", + roomId: portal.mxid, + }); + } + + async #handleRemoteDeliveryReceipt(event: RemoteDeliveryReceipt): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + const target = await this.#remoteTargetMessage(event); + if (!target?.eventId) { + throw new Error(`No Matrix message stored for remote delivery receipt target ${event.getTargetMessage()}`); + } + await this.#matrixClient.receipts.send({ + eventId: target.eventId, + receiptType: "m.read.private", + roomId: portal.mxid, + }); + } + + async #handleRemoteMarkUnread(event: RemoteMarkUnread): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + if (event.getUnread()) { + await this.setPortalMetadata(event.getPortalKey(), { ...metadataRecord(portal.metadata), unread: true }); + return; + } + const target = await this.#remoteTargetMessage(event); + if (target?.eventId) { + await this.#matrixClient.messages.markRead({ + eventId: target.eventId, + roomId: portal.mxid, + }); + } + await this.setPortalMetadata(event.getPortalKey(), { ...metadataRecord(portal.metadata), unread: false }); + } + + async #handleRemoteTyping(event: RemoteTyping): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) return; + await this.#matrixClient.typing.set(stripUndefined({ + roomId: portal.mxid, + timeoutMs: event.getTimeoutMs?.(), + typing: event.isTyping(), + })); + } + async #handleRemoteChatInfoChange(event: RemoteChatInfoChange): Promise { const portal = this.#portalForRemoteEvent(event); if (!portal) return; const change = await event.getChatInfoChange(this.#requestContext()); - const metadata = { - ...(typeof portal.metadata === "object" && portal.metadata !== null ? portal.metadata : {}), - chatInfo: change, - }; - await this.setPortalMetadata(portal.portalKey, metadata); + await this.#processChatInfoChange(portal, change); + } + + async #processChatInfoChange(portal: Portal, change: ChatInfoChange): Promise { + const info = change.chatInfo; + if (info) { + await this.#sendPortalInfoState(portal, info); + await this.#updatePortalInfo(portal, info); + } + if (change.memberChanges) { + const metadata = { + ...metadataRecord(portal.metadata), + memberChanges: change.memberChanges, + }; + await this.setPortalMetadata(portal.portalKey, metadata); + } + } + + async #sendPortalInfoState(portal: Portal, info: ChatInfo): Promise { + if (!portal.mxid) return; + if (info.name !== undefined && info.name !== portal.name) { + await this.roomState.set({ + content: { name: info.name }, + eventType: "m.room.name", + portal, + }); + } + if (info.topic !== undefined && info.topic !== portal.topic) { + await this.roomState.set({ + content: { topic: info.topic }, + eventType: "m.room.topic", + portal, + }); + } + if (info.avatar !== undefined && avatarStateValue(info.avatar) !== avatarStateValue(portal.avatar)) { + await this.roomState.set({ + content: { url: info.avatar.remove ? "" : avatarStateValue(info.avatar) ?? "" }, + eventType: "m.room.avatar", + portal, + }); + } + } + + async #updatePortalInfo(portal: Portal, info: ChatInfo): Promise { + const updated = stripUndefined({ + ...portal, + ...(info.avatar !== undefined ? { avatar: info.avatar.remove ? undefined : info.avatar } : {}), + ...(info.name !== undefined ? { name: info.name } : {}), + ...(info.roomType !== undefined ? { roomType: info.roomType } : {}), + ...(info.topic !== undefined ? { topic: info.topic } : {}), + metadata: { + ...metadataRecord(portal.metadata), + ...(info.canBackfill !== undefined ? { canBackfill: info.canBackfill } : {}), + ...(info.extraUpdates !== undefined ? { extraUpdates: info.extraUpdates } : {}), + ...(info.members !== undefined ? { members: info.members } : {}), + }, + }) as Portal; + this.registerPortal(updated); + return updated; } async #handleRemoteChatDelete(event: RemoteChatDelete): Promise { @@ -1262,7 +1803,7 @@ export class RuntimeBridge implements PickleBridge { const converted = await message.convertMessage(this.#requestContext(), portal, this.#matrixIntent()); for (const part of converted.parts) { const event: MatrixAppserviceBatchSendOptions["events"][number] = { - content: part.content, + content: convertedPartContent(part), sender: message.getSender().sender, }; const timestamp = eventTimestamp(message); @@ -1276,20 +1817,90 @@ export class RuntimeBridge implements PickleBridge { #matrixIntent(): MatrixIntent { return { client: this.#matrixClient, - sendMessage: async (roomId, content) => { - const type = "m.room.message"; - const transactionId = `pickle-bridge-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const result = await this.#matrixClient.raw.request({ - body: content, - method: "PUT", - path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(type)}/${transactionId}`, + sendMessage: (roomId, content) => { + const body = stringContent(content.body); + const msgtype = stringContent(content.msgtype); + const messageType = msgtype === "m.notice" || msgtype === "m.emote" ? msgtype : "m.text"; + return this.#matrixClient.messages.send({ + content, + messageType, + roomId, + text: body ?? "", }); - const eventId = eventIdFromRaw(result.body); - return { eventId, raw: result.raw ?? result.body ?? result, roomId }; }, }; } + async #remoteTargetMessage(event: RemoteEdit | RemoteReaction | RemoteReactionRemove | RemoteMessageRemove): Promise { + if (hasMethod(event, "getTargetDBMessage")) { + const bundled = (event as RemoteEventWithBundledParts).getTargetDBMessage(); + for (const message of bundled) { + if (message.mxid) { + return { + eventId: message.mxid, + raw: message.metadata, + roomId: this.#portalForRemoteEvent(event)?.mxid ?? "", + }; + } + } + } + const partId = hasMethod(event, "getTargetMessagePart") + ? (event as RemoteEventWithTargetPart).getTargetMessagePart() + : "0"; + return await this.#remoteStoredMessage(event.getTargetMessage(), partId); + } + + async #remoteTargetMessages(event: RemoteEdit | RemoteMessageRemove): Promise<{ db: Message[]; sent: SentEvent[] }> { + if (hasMethod(event, "getTargetDBMessage")) { + const bundled = (event as RemoteEventWithBundledParts).getTargetDBMessage(); + const sent = bundled.flatMap((message) => message.mxid + ? [{ + eventId: message.mxid, + raw: message.metadata, + roomId: this.#portalForRemoteEvent(event)?.mxid ?? "", + }] + : []); + if (sent.length > 0) return { db: bundled, sent }; + } + if (hasMethod(event, "getTargetMessagePart")) { + const partId = (event as RemoteEventWithTargetPart).getTargetMessagePart(); + const part = await this.#remoteStoredMessage(event.getTargetMessage(), partId); + return part ? { + db: [messageFromSentEvent(event.getTargetMessage(), partId, part)], + sent: [part], + } : { db: [], sent: [] }; + } + const first = await this.#remoteStoredMessage(event.getTargetMessage(), "0"); + return first ? { + db: [messageFromSentEvent(event.getTargetMessage(), "0", first)], + sent: [first], + } : { db: [], sent: [] }; + } + + async #remoteStoredMessage(messageId: string, partId: string): Promise { + const key = messagePartKey(messageId, partId); + return this.#messages.get(key) ?? await this.#dataStore?.getMessage(key) ?? null; + } + + #matchingRemoteTarget(existing: SentEvent[], partId: string | undefined, index: number): SentEvent | undefined { + if (partId) { + return existing[index] ?? existing[0]; + } + return existing[index] ?? existing[0]; + } + + #convertedEditPartTarget(part: ConvertedEditPart, existing: { db: Message[]; sent: SentEvent[] }, index: number): SentEvent | undefined { + if (part.part?.mxid) { + return { + eventId: part.part.mxid, + raw: part.part.metadata, + roomId: existing.sent[index]?.roomId ?? existing.sent[0]?.roomId ?? "", + }; + } + const partId = part.id ?? part.part?.partId; + return this.#matchingRemoteTarget(existing.sent, partId, index); + } + async #sendRemoteMessagePart(roomId: string, sender: string, content: Record, timestamp?: number): Promise { if (this.#appserviceOptions && sender.startsWith("@")) { const sendOptions = stripUndefined({ @@ -1370,10 +1981,122 @@ function isGenericEvent(event: MatrixClientEvent, kind: string): event is Generi return event.kind === kind && "content" in event && typeof event.content === "object" && event.content !== null; } +function isMatrixMarkedUnreadEvent(event: MatrixClientEvent): event is GenericMatrixEvent { + if (!("content" in event) || !isRecord(event.content)) return false; + if (!("roomId" in event) || typeof event.roomId !== "string") return false; + const type = "type" in event && typeof event.type === "string" ? event.type : undefined; + if (type === "m.marked_unread" || type === "com.beeper.marked_unread") return true; + return event.kind === "accountData" && ( + event.content.unread !== undefined + || event.content.marked_unread !== undefined + || event.content.markedUnread !== undefined + ); +} + +function isMatrixRoomNameEvent(event: MatrixClientEvent): event is GenericMatrixEvent { + return isGenericEvent(event, "roomState") && eventType(event) === "m.room.name"; +} + +function isMatrixRoomTopicEvent(event: MatrixClientEvent): event is GenericMatrixEvent { + return isGenericEvent(event, "roomState") && eventType(event) === "m.room.topic"; +} + +function isMatrixRoomAvatarEvent(event: MatrixClientEvent): event is GenericMatrixEvent { + return isGenericEvent(event, "roomState") && eventType(event) === "m.room.avatar"; +} + +function isMatrixMembershipEvent(event: MatrixClientEvent): event is GenericMatrixEvent { + return isGenericEvent(event, "membership") + || (isGenericEvent(event, "roomState") && eventType(event) === "m.room.member"); +} + +function isMatrixDeleteChatEvent(event: MatrixClientEvent): event is GenericMatrixEvent { + if (!("content" in event) || !isRecord(event.content)) return false; + if (!("roomId" in event) || typeof event.roomId !== "string") return false; + const type = "type" in event && typeof event.type === "string" ? event.type : undefined; + return type === "com.beeper.delete_chat" + || type === "com.beeper.chat.delete" + || type === "com.beeper.chat.deleted" + || (event.kind === "accountData" && event.content.delete_chat === true) + || (event.kind === "accountData" && event.content.deleted === true); +} + +function eventType(event: MatrixClientEvent): string | undefined { + return "type" in event && typeof event.type === "string" ? event.type : undefined; +} + +function avatarStateValue(avatar: { mxc?: string; remove?: boolean; url?: string } | undefined): string | undefined { + if (!avatar || avatar.remove) return ""; + return avatar.mxc ?? avatar.url; +} + +function ghostAvatarURL(ghost: Ghost): string | undefined { + if (!ghost.avatar) return undefined; + if (ghost.avatar.remove) return ""; + return ghost.avatar.mxc; +} + +function isMatrixEditEvent(event: MatrixMessageEvent): boolean { + return Boolean(event.edited && matrixEditTargetEventId(event)); +} + +function matrixEditTargetEventId(event: MatrixMessageEvent): string | undefined { + if (event.replaces) return event.replaces; + if (event.relation?.type === "m.replace") return event.relation.eventId; + const relates = isRecord(event.content["m.relates_to"]) ? event.content["m.relates_to"] : undefined; + if (isRecord(relates) && relates.rel_type === "m.replace" && typeof relates.event_id === "string") { + return relates.event_id; + } + return undefined; +} + +function matrixRedactionTargetEventId(event: GenericMatrixEvent): string | undefined { + const raw = isRecord(event.raw) ? event.raw : undefined; + if (typeof raw?.redacts === "string") return raw.redacts; + if (typeof event.content.redacts === "string") return event.content.redacts; + return undefined; +} + +function matrixReadReceipts(content: Record): Array<{ eventId: string; receiptType: string; userId: string }> { + const receipts: Array<{ eventId: string; receiptType: string; userId: string }> = []; + for (const [eventId, byType] of Object.entries(content)) { + if (!eventId.startsWith("$") || !isRecord(byType)) continue; + for (const [receiptType, byUser] of Object.entries(byType)) { + if (receiptType !== "m.read" && receiptType !== "m.read.private") continue; + if (!isRecord(byUser)) continue; + for (const userId of Object.keys(byUser)) { + if (userId.startsWith("@")) receipts.push({ eventId, receiptType, userId }); + } + } + } + return receipts; +} + +function matrixMembershipAction(event: GenericMatrixEvent): MatrixMembership["action"] | undefined { + const membership = stringValue(event.content.membership); + const prevContent = isRecord(event.unsigned?.prev_content) ? event.unsigned.prev_content : undefined; + const prevMembership = stringValue(prevContent?.membership); + if (membership === "invite") return "invite"; + if (membership === "ban") return "ban"; + if (membership === "leave") { + if (prevMembership === "invite") return "revoke_invite"; + return event.stateKey === event.sender?.userId ? "leave" : "kick"; + } + return undefined; +} + function hasMethod(value: object, method: T): value is object & Record unknown> { return method in value && typeof (value as Record)[method] === "function"; } +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + function appserviceBotUserId(options: MatrixAppserviceInitOptions): string { return `@${options.registration.senderLocalpart}:${options.homeserverDomain}`; } @@ -1529,14 +2252,13 @@ function messagePartKey(messageId: string, partId: string): string { return `${messageId}\u0000${partId}`; } -function eventIdFromRaw(body: unknown): string { - if (body && typeof body === "object" && typeof (body as { event_id?: unknown }).event_id === "string") { - return (body as { event_id: string }).event_id; - } - if (body && typeof body === "object" && typeof (body as { eventId?: unknown }).eventId === "string") { - return (body as { eventId: string }).eventId; - } - return ""; +function messageFromSentEvent(messageId: string, partId: string, sent: SentEvent): Message { + return { + id: messageId, + mxid: sent.eventId, + partId, + timestamp: new Date(), + }; } function eventTimestamp(event: RemoteEvent): number | undefined { @@ -1593,27 +2315,6 @@ function domainFromUserID(userId: string): string { return userId.slice(index + 1); } -function beeperAppServiceOptions(input: { - address: string | undefined; - baseDomain: string | undefined; - bridge: string; - bridgeType: string | undefined; - getOnly: boolean | undefined; - homeserverDomain: string | undefined; - token: string; -}) { - const output = { - bridge: input.bridge, - token: input.token, - } as Parameters[0]; - if (input.address !== undefined) output.address = input.address; - if (input.baseDomain !== undefined) output.baseDomain = input.baseDomain; - if (input.bridgeType !== undefined) output.bridgeType = input.bridgeType; - if (input.getOnly !== undefined) output.getOnly = input.getOnly; - if (input.homeserverDomain !== undefined) output.homeserverDomain = input.homeserverDomain; - return output; -} - function normalizeHTTPProxyResponse(response: { body?: unknown; headers?: Record; status: number }): HTTPProxyResponse { const headers: Record = {}; for (const [key, value] of Object.entries(response.headers ?? {})) { @@ -1640,6 +2341,10 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function metadataRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + function streamTransactionTrace(value: unknown): Record | undefined { if (!isRecord(value)) return undefined; const content = isRecord(value.content) ? value.content : {}; @@ -1671,3 +2376,7 @@ function convertedMessageFromOptions(options: BridgeRemoteMessageOptions { + return part.extra ? { ...part.content, ...part.extra } : part.content; +} diff --git a/packages/bridge/src/events.ts b/packages/bridge/src/events.ts index bf8e55c..fda7a99 100644 --- a/packages/bridge/src/events.ts +++ b/packages/bridge/src/events.ts @@ -1,10 +1,15 @@ import type { BridgeRequestContext, + ChatInfoChange, ConvertedMessage, + CreateRemoteChatInfoChangeOptions, CreateRemoteMessageOptions, MatrixIntent, MessageID, Portal, + RemoteChatInfoChange, + RemoteEventWithStreamOrder, + RemoteEventWithTimestamp, RemoteEventType, RemoteMessage, RemoteMessageWithTransactionID, @@ -49,3 +54,28 @@ export function createRemoteMessage(options: CreateRemoteMessageOptions): }, }; } + +export function createRemoteChatInfoChange(options: CreateRemoteChatInfoChangeOptions): RemoteChatInfoChange & RemoteEventWithTimestamp & RemoteEventWithStreamOrder { + const timestamp = options.timestamp ?? new Date(); + const streamOrder = options.streamOrder ?? timestamp.getTime(); + return { + getChatInfoChange(): ChatInfoChange { + return options.chatInfoChange; + }, + getPortalKey() { + return options.portalKey; + }, + getSender() { + return options.sender; + }, + getStreamOrder() { + return streamOrder; + }, + getTimestamp() { + return timestamp; + }, + getType(): RemoteEventType { + return "chat_info_change"; + }, + }; +} diff --git a/packages/bridge/src/media-message.test.ts b/packages/bridge/src/media-message.test.ts new file mode 100644 index 0000000..e5339be --- /dev/null +++ b/packages/bridge/src/media-message.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { bridgeMediaMessageContent, uploadBridgeMediaMessage } from "./media-message"; + +describe("bridge media messages", () => { + it("uploads bytes and returns a Matrix media message part", async () => { + const upload = vi.fn(async () => ({ contentUri: "mxc://example/file", raw: { ok: true } })); + + await expect(uploadBridgeMediaMessage({ + media: { upload }, + }, { + bytes: new Uint8Array([1, 2, 3]), + caption: "caption", + filename: "a.png", + kind: "image", + })).resolves.toEqual({ + content: { + body: "caption", + filename: "a.png", + msgtype: "m.image", + url: "mxc://example/file", + }, + part: { + content: { + body: "caption", + filename: "a.png", + msgtype: "m.image", + url: "mxc://example/file", + }, + type: "m.room.message", + }, + upload: { contentUri: "mxc://example/file", raw: { ok: true } }, + }); + expect(upload).toHaveBeenCalledWith({ + bytes: new Uint8Array([1, 2, 3]), + filename: "a.png", + }); + }); + + it("maps media kinds to Matrix msgtypes", () => { + expect(bridgeMediaMessageContent({ contentUri: "mxc://x", kind: "video" })).toMatchObject({ msgtype: "m.video" }); + expect(bridgeMediaMessageContent({ contentUri: "mxc://x", kind: "audio" })).toMatchObject({ msgtype: "m.audio" }); + expect(bridgeMediaMessageContent({ contentUri: "mxc://x", kind: "file" })).toMatchObject({ body: "attachment", msgtype: "m.file" }); + }); +}); diff --git a/packages/bridge/src/media-message.ts b/packages/bridge/src/media-message.ts new file mode 100644 index 0000000..717599b --- /dev/null +++ b/packages/bridge/src/media-message.ts @@ -0,0 +1,66 @@ +import type { MatrixClient, UploadMediaResult } from "@beeper/pickle"; +import type { ConvertedMessagePart } from "./types"; + +export type BridgeMediaKind = "image" | "video" | "audio" | "file"; + +export interface BridgeMediaUploadClient { + media: Pick; +} + +export interface BridgeMediaMessageOptions { + bytes: Uint8Array; + caption?: string; + filename?: string; + kind?: BridgeMediaKind; +} + +export interface BridgeUploadedMediaMessage { + content: Record; + part: ConvertedMessagePart; + upload: UploadMediaResult; +} + +export async function uploadBridgeMediaMessage( + client: BridgeMediaUploadClient, + options: BridgeMediaMessageOptions, +): Promise { + const upload = await client.media.upload({ + bytes: options.bytes, + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }); + const content = bridgeMediaMessageContent({ + contentUri: upload.contentUri, + kind: options.kind ?? "file", + ...(options.caption !== undefined ? { caption: options.caption } : {}), + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }); + return { + content, + part: { + content, + type: "m.room.message", + }, + upload, + }; +} + +export function bridgeMediaMessageContent(options: { + caption?: string; + contentUri: string; + filename?: string; + kind: BridgeMediaKind; +}): Record { + return { + body: options.caption ?? options.filename ?? "attachment", + msgtype: mediaMsgtype(options.kind), + url: options.contentUri, + ...(options.filename ? { filename: options.filename } : {}), + }; +} + +function mediaMsgtype(kind: BridgeMediaKind): string { + if (kind === "image") return "m.image"; + if (kind === "video") return "m.video"; + if (kind === "audio") return "m.audio"; + return "m.file"; +} diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/node.ts similarity index 81% rename from packages/bridge/src/index.ts rename to packages/bridge/src/node.ts index 96f5114..dcd179f 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/node.ts @@ -6,13 +6,7 @@ import { RuntimeBridge } from "./bridge"; import { createBridgeDataStore, getOrCreateAppserviceDeviceId } from "./store"; import type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge } from "./types"; -export { createBridgeDataStore, MatrixBridgeDataStore } from "./store"; -export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; -export { createRemoteMessage } from "./events"; -export type * from "./beeper"; -export type * from "./store"; -export type * from "./types"; -export { RuntimeBridge } from "./bridge"; +export type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge }; export function createBridge(options: CreateNodeBridgeOptions): PickleBridge { return new RuntimeBridge(options, createMatrixClient(options.matrix)); @@ -22,7 +16,7 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) const store = options.store ?? options.matrix?.store ?? createFileMatrixStore(defaultDataDir(options)); const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit({ bridge: options.bridge, - token: options.account.accessToken, + token: requiredAccount(options).accessToken, ...(options.address ? { address: options.address } : {}), ...(options.baseDomain ? { baseDomain: options.baseDomain } : {}), ...(options.bridgeType ? { bridgeType: options.bridgeType } : {}), @@ -44,16 +38,19 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) appservice, beeper: { bridge: options.bridge, - ownerUserId: options.account.userId, + ...(options.account?.userId ?? options.ownerUserId ? { ownerUserId: options.account?.userId ?? options.ownerUserId } : {}), ...(options.bridgeType ? { bridgeType: options.bridgeType } : {}), }, connector: options.connector, dataStore: options.dataStore ?? createBridgeDataStore(store), ...(options.log ? { log: options.log } : {}), matrix, - }, createMatrixClient({ - ...matrix, - })); + }, createMatrixClient(matrix)); +} + +function requiredAccount(options: CreateNodeBeeperBridgeOptions) { + if (!options.account) throw new Error("createBeeperBridge requires account unless matrix.appservice is provided"); + return options.account; } function defaultDataDir(options: { bridge: string; dataDir?: string }): string { diff --git a/packages/bridge/src/provisioning.test.ts b/packages/bridge/src/provisioning.test.ts index fc308ae..3c6743e 100644 --- a/packages/bridge/src/provisioning.test.ts +++ b/packages/bridge/src/provisioning.test.ts @@ -12,7 +12,7 @@ describe("handleProvisioningHTTPProxy", () => { })).resolves.toMatchObject({ body: { group_creation: {}, - resolve_identifier: { createDM: true }, + resolve_identifier: { create_dm: true }, }, status: 200, }); @@ -32,7 +32,7 @@ describe("handleProvisioningHTTPProxy", () => { await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { method: "POST", path: "/_matrix/provision/v3/create_dm/intern", - query: "login_id=cloud-login-id", + query: "login_id=intern", })).resolves.toMatchObject({ body: { dm_room_mxid: "!sidechat:example", @@ -45,6 +45,117 @@ describe("handleProvisioningHTTPProxy", () => { expect(runtime.resolveIdentifier).toHaveBeenCalledWith({ id: "intern" }, "intern", true); }); + + it("lists contacts through provisioning when the bridge supports contact lists", async () => { + const runtime = provisioningRuntime(); + + await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { + method: "GET", + path: "/_matrix/provision/v3/contacts", + query: "login_id=intern", + })).resolves.toMatchObject({ + body: { + contacts: [{ + id: "intern", + identifiers: ["openclaw:agent:intern", "@intern:example"], + mxid: "@intern:example", + name: "Intern", + }], + }, + status: 200, + }); + + expect(runtime.listContacts).toHaveBeenCalledWith({ id: "intern" }); + }); + + it("searches users through the BridgeV2 search_users endpoint", async () => { + const runtime = provisioningRuntime(); + + await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { + body: { query: "codex" }, + method: "POST", + path: "/_matrix/provision/v3/search_users", + query: "login_id=intern", + })).resolves.toMatchObject({ + body: { + results: [{ + id: "intern", + identifiers: ["openclaw:agent:intern", "@intern:example"], + mxid: "@intern:example", + name: "Intern", + }], + }, + status: 200, + }); + + expect(runtime.searchUsers).toHaveBeenCalledWith({ id: "intern" }, "codex"); + }); + + it("runs room backfill through provisioning", async () => { + const runtime = provisioningRuntime(); + + await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { + body: { + cursor: "older", + mark_read: true, + }, + method: "POST", + path: "/_matrix/provision/v3/backfill/!room%3Aexample", + query: "login_id=intern&limit=25", + })).resolves.toMatchObject({ + body: { + done: false, + has_more: true, + next_batch: "next", + queued: false, + task: { + cursor: "next", + done: false, + portal_key: { id: "sidechat", receiver: "intern" }, + user_login_id: "intern", + }, + }, + status: 200, + }); + + expect(runtime.backfill).toHaveBeenCalledWith({ id: "intern" }, "!room:example", { + count: 25, + cursor: "older", + limit: 25, + markRead: true, + }); + }); + + it("does not fall back to another login when an explicit provisioning login_id is missing", async () => { + const runtime = provisioningRuntime(); + + await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { + method: "POST", + path: "/_matrix/provision/v3/create_dm/intern", + query: "login_id=missing", + })).resolves.toMatchObject({ + body: { + errcode: "M_NOT_FOUND", + error: "Login not found", + }, + status: 404, + }); + await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { + method: "GET", + path: "/_matrix/provision/v3/contacts", + query: "login_id=missing", + })).resolves.toMatchObject({ + body: { + errcode: "M_NOT_FOUND", + error: "Login not found", + }, + status: 404, + }); + + expect(runtime.resolveIdentifier).not.toHaveBeenCalled(); + expect(runtime.listContacts).not.toHaveBeenCalled(); + expect(runtime.backfill).not.toHaveBeenCalled(); + }); }); function provisioningRuntime(): ProvisioningRuntime { @@ -58,6 +169,30 @@ function provisioningRuntime(): ProvisioningRuntime { }), createLogin: vi.fn(), listLogins: () => [login], + listContacts: vi.fn(async () => ({ + contacts: [{ + ghost: { displayName: "Intern", id: "intern", identifiers: ["openclaw:agent:intern", "@intern:example"], mxid: "@intern:example" }, + userId: "@intern:example", + }], + })), + searchUsers: vi.fn(async () => ({ + results: [{ + ghost: { displayName: "Intern", id: "intern", identifiers: ["openclaw:agent:intern", "@intern:example"], mxid: "@intern:example" }, + userId: "@intern:example", + }], + })), + backfill: vi.fn(async () => ({ + cursor: "next", + hasMore: true, + queued: false, + task: { + cursor: "next", + done: false, + pending: false, + portalKey: { id: "sidechat", receiver: "intern" }, + userLoginId: "intern", + }, + })), loginFlows: () => [], loadLogin: vi.fn(), requestContext: vi.fn(), diff --git a/packages/bridge/src/provisioning.ts b/packages/bridge/src/provisioning.ts index c8a232f..c2d26c6 100644 --- a/packages/bridge/src/provisioning.ts +++ b/packages/bridge/src/provisioning.ts @@ -8,8 +8,13 @@ import type { LoginStep, LoginUserInput, LoginCookieInput, + ListContactsResponse, NetworkGeneralCapabilities, ResolveIdentifierResponse, + BackfillQueueResult, + BackfillQueueParams, + ResolveIdentifierCapabilities, + SearchUsersResponse, UserLogin, } from "./types"; @@ -19,10 +24,15 @@ export interface ProvisioningRuntime { listLogins(): UserLogin[]; loginFlows(): unknown[]; loadLogin(login: UserLogin): Promise; + listContacts?(login: UserLogin): Promise; requestContext(): BridgeRequestContext; resolveIdentifier(login: UserLogin, identifier: string, createDM: boolean): Promise; + searchUsers?(login: UserLogin, query: string): Promise; + backfill?(login: UserLogin, roomId: string, params: ProvisioningBackfillParams): Promise; } +export type ProvisioningBackfillParams = Pick; + export interface ProvisioningState { logins: Map; } @@ -41,6 +51,30 @@ export async function handleProvisioningHTTPProxy(runtime: ProvisioningRuntime, return jsonHTTPResponse(200, { login_ids: runtime.listLogins().map((login) => login.id) }); } + if (method === "GET" && path === "/_matrix/provision/v3/contacts") { + if (!runtime.listContacts) return notSupportedResponse("Contact listing is not supported"); + const login = provisioningLogin(runtime, request); + if (!login) return jsonHTTPResponse(404, matrixError("M_NOT_FOUND", "Login not found")); + return jsonHTTPResponse(200, contactsListResponse(await runtime.listContacts(login))); + } + + if (method === "POST" && path === "/_matrix/provision/v3/search_users") { + if (!runtime.searchUsers) return notSupportedResponse("User search is not supported"); + const login = provisioningLogin(runtime, request); + if (!login) return jsonHTTPResponse(404, matrixError("M_NOT_FOUND", "Login not found")); + return jsonHTTPResponse(200, searchUsersResponse(await runtime.searchUsers(login, bodyStringParam(request, "query") ?? ""))); + } + + const backfill = match(path, /^\/_matrix\/provision\/v3\/backfill\/([^/]+)$/); + if ((method === "GET" || method === "POST") && backfill) { + if (!runtime.backfill) return notSupportedResponse("Backfill is not supported"); + const [roomId] = backfill; + if (!roomId) return null; + const login = provisioningLogin(runtime, request); + if (!login) return jsonHTTPResponse(404, matrixError("M_NOT_FOUND", "Login not found")); + return jsonHTTPResponse(200, backfillResponse(await runtime.backfill(login, roomId, backfillParams(request)))); + } + const createDM = match(path, /^\/_matrix\/provision\/v3\/create_dm\/([^/]+)$/); if (method === "POST" && createDM) { const [identifier] = createDM; @@ -85,7 +119,7 @@ function provisioningLogin(runtime: ProvisioningRuntime, request: HTTPProxyReque const loginId = queryParam(request.query, "login_id"); if (loginId) { const matching = logins.find((login) => login.id === loginId); - if (matching) return matching; + return matching ?? null; } return logins[0] ?? null; } @@ -129,20 +163,73 @@ export function jsonHTTPResponse(status: number, body: unknown): HTTPProxyRespon function capabilitiesResponse(capabilities: NetworkGeneralCapabilities): unknown { return { group_creation: capabilities.provisioning?.groupCreation ?? {}, - resolve_identifier: capabilities.provisioning?.resolveIdentifier ?? {}, + resolve_identifier: resolveIdentifierCapabilitiesResponse(capabilities.provisioning?.resolveIdentifier), }; } +function resolveIdentifierCapabilitiesResponse(capabilities?: ResolveIdentifierCapabilities): Record { + return stripUndefined({ + any_phone: capabilities?.anyPhone, + contact_list: capabilities?.contactList, + create_dm: capabilities?.createDM, + lookup_email: capabilities?.lookupEmail, + lookup_phone: capabilities?.lookupPhone, + lookup_username: capabilities?.lookupUsername, + search: capabilities?.search, + }); +} + function resolvedIdentifierResponse(resolved: ResolveIdentifierResponse): Record { return stripUndefined({ avatar_url: resolved.ghost?.avatar?.url, dm_room_mxid: resolved.portal?.mxid, id: resolved.ghost?.id ?? resolved.userId, + identifiers: resolved.ghost?.identifiers, mxid: resolved.userId ?? resolved.ghost?.mxid, name: resolved.ghost?.displayName, }); } +function contactsListResponse(response: ListContactsResponse): Record { + return stripUndefined({ + contacts: response.contacts.map((contact) => resolvedIdentifierResponse(contact)), + next_batch: response.nextBatch, + }); +} + +function searchUsersResponse(response: SearchUsersResponse): Record { + return { + results: response.results.map((result) => resolvedIdentifierResponse(result)), + }; +} + +function backfillResponse(response: BackfillQueueResult): Record { + return stripUndefined({ + cursor: response.cursor, + done: response.task?.done ?? (response.hasMore === undefined ? undefined : !response.hasMore), + forward: response.forward, + has_more: response.hasMore, + mark_read: response.markRead, + next_batch: response.cursor ?? response.task?.cursor, + pending: response.pending ?? response.task?.pending, + progress: response.progress, + queued: response.queued, + task: response.task ? stripUndefined({ + batch_count: response.task.batchCount, + bridge_id: response.task.bridgeId, + completed_at: response.task.completedAt?.toISOString(), + cursor: response.task.cursor, + dispatched_at: response.task.dispatchedAt?.toISOString(), + done: response.task.done, + next_dispatch_at: response.task.nextDispatchAt?.toISOString(), + oldest_message_id: response.task.oldestMessageId, + pending: response.task.pending, + portal_key: response.task.portalKey, + user_login_id: response.task.userLoginId, + }) : undefined, + }); +} + function loginStepResponse(loginId: string, step: LoginStep): Record { return { login_id: loginId, @@ -198,6 +285,10 @@ function matrixError(errcode: string, error: string): Record { return { errcode, error }; } +function notSupportedResponse(message: string): HTTPProxyResponse { + return jsonHTTPResponse(501, matrixError("M_UNRECOGNIZED", message)); +} + function match(path: string, regex: RegExp): string[] | null { const result = regex.exec(path); const captures = result?.slice(1); @@ -211,6 +302,57 @@ function queryParam(rawQuery: string | undefined, key: string): string | undefin return new URLSearchParams(rawQuery.startsWith("?") ? rawQuery.slice(1) : rawQuery).get(key) ?? undefined; } +function intQueryParam(rawQuery: string | undefined, key: string): number | undefined { + const value = queryParam(rawQuery, key); + if (!value) return undefined; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function boolQueryParam(rawQuery: string | undefined, key: string): boolean | undefined { + return boolValue(queryParam(rawQuery, key)); +} + +function bodyParam(request: HTTPProxyRequest, key: string): unknown { + if (!request.body || typeof request.body !== "object") return undefined; + return (request.body as Record)[key]; +} + +function bodyStringParam(request: HTTPProxyRequest, key: string): string | undefined { + const value = bodyParam(request, key); + return typeof value === "string" ? value : undefined; +} + +function bodyIntParam(request: HTTPProxyRequest, key: string): number | undefined { + const value = bodyParam(request, key); + if (typeof value !== "number" && typeof value !== "string") return undefined; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function bodyBoolParam(request: HTTPProxyRequest, key: string): boolean | undefined { + return boolValue(bodyParam(request, key)); +} + +function boolValue(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value !== "string") return undefined; + if (["1", "true", "yes"].includes(value.toLowerCase())) return true; + if (["0", "false", "no"].includes(value.toLowerCase())) return false; + return undefined; +} + +function backfillParams(request: HTTPProxyRequest): ProvisioningBackfillParams { + return stripUndefined({ + count: intQueryParam(request.query, "count") ?? intQueryParam(request.query, "limit") ?? bodyIntParam(request, "count") ?? bodyIntParam(request, "limit"), + cursor: queryParam(request.query, "cursor") ?? queryParam(request.query, "from") ?? bodyStringParam(request, "cursor") ?? bodyStringParam(request, "from"), + forward: boolQueryParam(request.query, "forward") ?? bodyBoolParam(request, "forward"), + limit: intQueryParam(request.query, "limit") ?? bodyIntParam(request, "limit"), + markRead: boolQueryParam(request.query, "mark_read") ?? boolQueryParam(request.query, "markRead") ?? bodyBoolParam(request, "mark_read") ?? bodyBoolParam(request, "markRead"), + pending: boolQueryParam(request.query, "pending") ?? bodyBoolParam(request, "pending"), + }); +} + function hasMethod(value: object, method: T): value is object & Record unknown> { return method in value && typeof (value as Record)[method] === "function"; } diff --git a/packages/bridge/src/room-state.ts b/packages/bridge/src/room-state.ts new file mode 100644 index 0000000..9100d60 --- /dev/null +++ b/packages/bridge/src/room-state.ts @@ -0,0 +1,29 @@ +export const BeeperAIRoomStateEvent = { + additionalPrompt: "com.beeper.ai.additional_prompt", + model: "com.beeper.ai.model", + tools: "com.beeper.ai.tools", +} as const; + +export type BeeperAIRoomStateEventType = typeof BeeperAIRoomStateEvent[keyof typeof BeeperAIRoomStateEvent]; + +export interface BeeperAIRoomModelState { + model: string; + name?: string; + reasoning?: string; + reasoning_mode?: string; +} + +export interface BeeperAIRoomPromptState { + prompt: string; +} + +export interface BeeperAIRoomToolsState { + disabled?: string[]; + fetch?: "beeper" | "native" | "off" | string; + search?: "beeper" | "native" | "off" | string; +} + +export type BeeperAIRoomStateContent = + | BeeperAIRoomModelState + | BeeperAIRoomPromptState + | BeeperAIRoomToolsState; diff --git a/packages/bridge/src/store.test.ts b/packages/bridge/src/store.test.ts new file mode 100644 index 0000000..44f676b --- /dev/null +++ b/packages/bridge/src/store.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MatrixStore } from "@beeper/pickle"; +import { MatrixBridgeDataStore } from "./store"; + +describe("MatrixBridgeDataStore", () => { + it("drops corrupt JSON values instead of failing startup loads", async () => { + const store = fakeMatrixStore({ + "pickle-bridge:bridge-status:current": new TextEncoder().encode('{"state":"running"}{"state":"stale"}'), + }); + const dataStore = new MatrixBridgeDataStore(store); + + await expect(dataStore.getBridgeStatus()).resolves.toBeNull(); + expect(store.delete).toHaveBeenCalledWith("pickle-bridge:bridge-status:current"); + }); +}); + +function fakeMatrixStore(values: Record): MatrixStore & { delete: ReturnType } { + const entries = new Map(Object.entries(values)); + return { + delete: vi.fn(async (key: string) => { + entries.delete(key); + }), + get: vi.fn(async (key: string) => entries.get(key) ?? null), + list: vi.fn(async (prefix: string) => Array.from(entries.keys()).filter((key) => key.startsWith(prefix))), + set: vi.fn(async (key: string, value: Uint8Array) => { + entries.set(key, value); + }), + }; +} diff --git a/packages/bridge/src/store.ts b/packages/bridge/src/store.ts index 8f95e1b..eb9d61f 100644 --- a/packages/bridge/src/store.ts +++ b/packages/bridge/src/store.ts @@ -138,7 +138,13 @@ export class MatrixBridgeDataStore implements BridgeDataStore { async #get(storageKey: string): Promise { const raw = await this.#store.get(storageKey); - return raw ? JSON.parse(new TextDecoder().decode(raw)) as T : null; + if (!raw) return null; + try { + return JSON.parse(new TextDecoder().decode(raw)) as T; + } catch { + await this.#store.delete(storageKey).catch(() => {}); + return null; + } } async #set(storageKey: string, value: unknown): Promise { diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index 65753d6..7ac6287 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -10,6 +10,7 @@ import type { MatrixEventSender, MatrixMessageEvent, MatrixReactionEvent, + RoomStateEvent, MatrixStore, SendMediaMessageOptions, SentEvent, @@ -18,6 +19,9 @@ import type { UserInfo as MatrixUserInfo, } from "@beeper/pickle"; import type { BridgeDataStore } from "./store"; +import type { BeeperTurnStream, CreateBeeperTurnStreamOptions } from "./beeper-stream"; + +export type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscription } from "@beeper/pickle"; export type BridgeID = string; export type UserID = string; @@ -160,6 +164,14 @@ export interface IdentifierResolvingNetworkAPI extends NetworkAPI { resolveIdentifier(ctx: BridgeRequestContext, identifier: ResolveIdentifierParams): Promise; } +export interface ContactListingNetworkAPI extends NetworkAPI { + listContacts(ctx: BridgeRequestContext, params: ListContactsParams): Promise; +} + +export interface UserSearchingNetworkAPI extends NetworkAPI { + searchUsers(ctx: BridgeRequestContext, params: SearchUsersParams): Promise; +} + export interface MessageRequestHandlingNetworkAPI extends NetworkAPI { handleMessageRequest(ctx: BridgeRequestContext, request: MessageRequest): Promise; } @@ -204,7 +216,7 @@ export interface PollHandlingNetworkAPI extends NetworkAPI { } export interface DisappearTimerChangingNetworkAPI extends NetworkAPI { - handleMatrixDisappearTimer(ctx: BridgeRequestContext, msg: MatrixDisappearTimer): Promise; + handleMatrixDisappearTimer(ctx: BridgeRequestContext, msg: MatrixDisappearTimer): Promise | boolean; } export interface MembershipHandlingNetworkAPI extends NetworkAPI { @@ -212,15 +224,15 @@ export interface MembershipHandlingNetworkAPI extends NetworkAPI { } export interface RoomNameHandlingNetworkAPI extends NetworkAPI { - handleMatrixRoomName(ctx: BridgeRequestContext, msg: MatrixRoomName): Promise; + handleMatrixRoomName(ctx: BridgeRequestContext, msg: MatrixRoomName): Promise | boolean; } export interface RoomTopicHandlingNetworkAPI extends NetworkAPI { - handleMatrixRoomTopic(ctx: BridgeRequestContext, msg: MatrixRoomTopic): Promise; + handleMatrixRoomTopic(ctx: BridgeRequestContext, msg: MatrixRoomTopic): Promise | boolean; } export interface RoomAvatarHandlingNetworkAPI extends NetworkAPI { - handleMatrixRoomAvatar(ctx: BridgeRequestContext, msg: MatrixRoomAvatar): Promise; + handleMatrixRoomAvatar(ctx: BridgeRequestContext, msg: MatrixRoomAvatar): Promise | boolean; } export interface MuteHandlingNetworkAPI extends NetworkAPI { @@ -406,7 +418,7 @@ export interface RemotePostHandler extends RemoteEvent { } export interface RemoteChatInfoChange extends RemoteEvent { - getChatInfoChange(ctx: BridgeRequestContext): Promise; + getChatInfoChange(ctx: BridgeRequestContext): Promise | ChatInfoChange; } export interface RemoteChatResync extends RemoteEvent {} @@ -507,9 +519,11 @@ export interface PickleBridge { readonly client: MatrixClient | null; readonly connector: BridgeConnector; readonly context: BridgeContext | null; + readonly roomState: BridgeRoomStateAPI; acceptMessageRequest(portalKey: PortalKey): Promise; createLogin(user: BridgeUser, flowId: string): Promise; createManagementRoom(options: BridgeCreateManagementRoomOptions): Promise; + createBeeperTurnStream(options: Omit): BeeperTurnStream; backfill(options: BridgeBackfillOptions): Promise; backfillMessages(login: UserLogin, params: FetchMessagesParams): Promise; backfillPortal(login: UserLogin, portal: PortalReference, params?: Omit): Promise; @@ -531,7 +545,7 @@ export interface PickleBridge { loadUserLogin(login: UserLogin): Promise; queue(login: UserLogin): RemoteEventQueue; queueRemoteEvent(login: UserLogin, event: RemoteEvent): QueueRemoteEventResult; - registerGhost(ghost: Ghost): void; + registerGhost(ghost: Ghost): Promise; registerManagementRoom(room: ManagementRoom): void; registerPortal(portal: Portal): void; resolveIdentifier(login: UserLogin, identifier: ResolveIdentifierParams): Promise; @@ -547,6 +561,25 @@ export interface PickleBridge { uploadMedia(options: UploadMediaOptions): Promise; } +export interface BridgeRoomStateAPI { + get(options: BridgeRoomStateGetOptions): Promise; + set(options: BridgeRoomStateSetOptions): Promise; +} + +export interface BridgeRoomStateGetOptions { + eventType: string; + roomId: RoomID; + stateKey?: string; +} + +export interface BridgeRoomStateSetOptions { + content: Record; + eventType: string; + portal?: Portal; + roomId?: RoomID; + stateKey?: string; +} + export interface CreateBridgeOptions { appservice?: MatrixAppserviceInitOptions; beeper?: BridgeBeeperOptions; @@ -563,7 +596,7 @@ export interface BridgeBeeperOptions { } export interface CreateBeeperBridgeOptions extends Omit { - account: MatrixAccount; + account?: MatrixAccount; address?: string; baseDomain?: string; bridge: string; @@ -573,6 +606,7 @@ export interface CreateBeeperBridgeOptions extends Omit>; + ownerUserId?: UserID; store?: MatrixStore; } @@ -648,6 +682,7 @@ export interface BridgeRemoteBackfillMessageOptions extends Omit
; info?: ChatInfo; initialState?: { content: Record; stateKey: string; type: string }[]; invite?: UserID[]; @@ -700,6 +735,7 @@ export interface MatrixCommandResponse { export type { MatrixAppserviceInitOptions, MatrixAppserviceSendMessageOptions, + SentEvent, }; export interface MatrixDispatchResult { @@ -729,10 +765,13 @@ export interface ProvisioningCapabilities { } export interface ResolveIdentifierCapabilities { + anyPhone?: boolean; contactList?: boolean; createDM?: boolean; + lookupEmail?: boolean; lookupPhone?: boolean; lookupUsername?: boolean; + search?: boolean; } export interface GroupTypeCapabilities { @@ -803,20 +842,26 @@ export interface UserLogin { } export interface Portal { + avatar?: Avatar; id: PortalID; metadata?: unknown; mxid?: string; + name?: string; portalKey: PortalKey; receiver?: UserLoginID; roomType?: "dm" | "group" | "space" | string; + topic?: string; } export interface Ghost { avatar?: Avatar; displayName?: string; id: GhostID; + identifiers?: string[]; + isBot?: boolean; metadata?: unknown; mxid?: string; + profile?: Record; } export type BridgeState = "starting" | "running" | "stopping" | "stopped" | "degraded" | "error"; @@ -886,6 +931,24 @@ export interface ResolveIdentifierResponse { userId?: UserID; } +export interface ListContactsParams { + limit?: number; + query?: string; +} + +export interface ListContactsResponse { + contacts: ResolveIdentifierResponse[]; + nextBatch?: string; +} + +export interface SearchUsersParams { + query: string; +} + +export interface SearchUsersResponse { + results: ResolveIdentifierResponse[]; +} + export interface UserProfile { avatarUrl?: string; displayName?: string; @@ -932,7 +995,19 @@ export interface ConvertedMessagePart { } export interface ConvertedEdit { - modifiedParts: ConvertedMessagePart[]; + addedParts?: ConvertedMessage; + deletedParts?: Message[]; + modifiedParts: ConvertedEditPart[]; +} + +export interface ConvertedEditPart { + content: Record; + dontBridge?: boolean; + extra?: Record; + id?: PartID; + part?: Message; + topLevelExtra?: Record; + type: string; } export interface UpsertResult { @@ -958,6 +1033,14 @@ export interface CreateRemoteMessageOptions { type?: "message" | "message_upsert"; } +export interface CreateRemoteChatInfoChangeOptions { + chatInfoChange: ChatInfoChange; + portalKey: PortalKey; + sender: EventSender; + streamOrder?: number; + timestamp?: Date; +} + export interface BridgeRemoteEventOptions { event: Omit & Record; portal: PortalReference; @@ -1026,7 +1109,9 @@ export interface MatrixRedaction { export interface MatrixReadReceipt { portal: Portal; + receiptType?: string; targetMessage: Message; + userId?: string; } export interface MatrixTyping { @@ -1082,6 +1167,7 @@ export interface MatrixTag { export interface MatrixMarkedUnread { portal: Portal; unread: boolean; + userId?: string; } export interface MatrixDeleteChat { @@ -1129,17 +1215,28 @@ export interface MessageCheckpoints { export interface ChatInfo { avatar?: Avatar; + canBackfill?: boolean; + extraUpdates?: Record; + members?: ChatMemberList; name?: string; participants?: UserID[]; + roomType?: "dm" | "group" | "space" | string; topic?: string; } export interface ChatInfoChange { - avatar?: Avatar; - name?: string; - participantsAdded?: UserID[]; - participantsRemoved?: UserID[]; - topic?: string; + chatInfo?: ChatInfo; + memberChanges?: ChatMemberList; +} + +export interface ChatMember { + membership?: "join" | "invite" | "leave" | "ban" | "knock" | string; + userId: UserID; +} + +export interface ChatMemberList { + isFull?: boolean; + members: ChatMember[]; } export interface Avatar { diff --git a/packages/bridge/tsdown.config.ts b/packages/bridge/tsdown.config.ts index 1e39556..62a9bda 100644 --- a/packages/bridge/tsdown.config.ts +++ b/packages/bridge/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/index.ts", "src/types.ts", "src/events.ts", "src/beeper.ts", "src/store.ts", "src/appservice-websocket.ts"], + entry: ["src/node.ts", "src/bridge.ts", "src/types.ts", "src/events.ts", "src/beeper.ts", "src/beeper-stream.ts", "src/media-message.ts", "src/room-state.ts", "src/store.ts", "src/appservice-websocket.ts"], format: ["esm"], dts: { sourcemap: false, @@ -13,7 +13,7 @@ export default defineConfig({ dts: ".d.ts", }), deps: { - neverBundle: ["@beeper/pickle", "@beeper/pickle/auth", "@beeper/pickle/node", "@beeper/pickle-state-file", "ws"], + neverBundle: ["@beeper/pickle", "@beeper/pickle/auth", "@beeper/pickle/beeper/auth", "@beeper/pickle/node", "@beeper/pickle-state-file", "ws"], }, target: false, }); diff --git a/packages/bridge/vitest.config.ts b/packages/bridge/vitest.config.ts index 092149c..8ea6f3e 100644 --- a/packages/bridge/vitest.config.ts +++ b/packages/bridge/vitest.config.ts @@ -4,6 +4,7 @@ export default defineProject({ resolve: { alias: { "@beeper/pickle/auth": new URL("../pickle/src/auth.ts", import.meta.url).pathname, + "@beeper/pickle/beeper/auth": new URL("../pickle/src/beeper/auth.ts", import.meta.url).pathname, "@beeper/pickle/node": new URL("../pickle/src/node.ts", import.meta.url).pathname, "@beeper/pickle": new URL("../pickle/src/index.ts", import.meta.url).pathname, }, diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 7e9eb32..5feed8a 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -1,6 +1,7 @@ { "name": "@beeper/pickle-cloudflare", "version": "0.1.0", + "private": true, "description": "Cloudflare Workers and Durable Objects helpers for Pickle", "type": "module", "homepage": "https://github.com/beeper/pickle#readme", diff --git a/packages/openclaw/.npmignore b/packages/openclaw/.npmignore new file mode 100644 index 0000000..e8009f3 --- /dev/null +++ b/packages/openclaw/.npmignore @@ -0,0 +1,9 @@ +coverage +node_modules +src +*.test.* +tsconfig.json +tsdown.config.ts +vitest.config.ts +!dist +!dist/** diff --git a/packages/openclaw/AGENTS.md b/packages/openclaw/AGENTS.md new file mode 100644 index 0000000..2be29df --- /dev/null +++ b/packages/openclaw/AGENTS.md @@ -0,0 +1,149 @@ +# OpenClaw Beeper plugin + +This package is the OpenClaw channel plugin for the Beeper bridge. Treat it as a +first-class OpenClaw network plugin backed by Pickle and bridgev2 semantics. + +## Local development + +From the Pickle repo root: + +```sh +pnpm --filter @beeper/openclaw build +``` + +From this package directory: + +```sh +pnpm build +``` + +OpenClaw loads the runtime entry from `dist/plugin-entry.mjs` and the setup +entry from `dist/setup-entry.mjs`, so rebuild before installing or restarting a +locally linked plugin. + +## Install or update the plugin + +For a published install: + +```sh +openclaw plugins install clawhub:@beeper/openclaw +``` + +For local development from this package directory: + +```sh +pnpm build +openclaw plugins install --link . +``` + +If working from the Pickle repo root, pass the package path instead: + +```sh +pnpm --filter @beeper/openclaw build +openclaw plugins install --link packages/openclaw +``` + +Check that OpenClaw discovered the plugin: + +```sh +openclaw plugins list +openclaw plugins inspect beeper +openclaw plugins doctor +``` + +Configure the channel through OpenClaw's setup UI or CLI: + +```sh +openclaw channels add +``` + +If the installed OpenClaw version exposes plugin channels through the channel +CLI, `openclaw channels add --channel beeper` may also work. + +## Restart and inspect runtime state + +Restart the gateway after changing plugin code or configuration: + +```sh +openclaw gateway restart +``` + +Inspect gateway and channel status: + +```sh +openclaw gateway status +openclaw channels list +openclaw channels status --probe +openclaw channels logs --channel beeper +``` + +For runtime debugging, run the gateway in the foreground: + +```sh +openclaw gateway run --verbose +``` + +Use raw stream logging only when investigating OpenClaw stream exposure: + +```sh +openclaw gateway run --verbose --raw-stream +``` + +## Testing + +Run focused package tests while iterating: + +```sh +pnpm --filter @beeper/openclaw exec vitest run +pnpm --filter @beeper/openclaw typecheck +pnpm --filter @beeper/openclaw build +``` + +Run Pickle native tests when changing generated contracts, appservice behavior, +room state, or bridge transport: + +```sh +cd packages/pickle/native +go test ./internal/core +cd - +pnpm --filter @beeper/pickle build:wasm +``` + +Run OpenClaw plugin compatibility through Crabpot from the Pickle repo root: + +```sh +npm run test:openclaw:plugins +``` + +The full suite is: + +```sh +npm run full-test +``` + +Do not run opt-in isolated execution checks unless the task explicitly requires +side effects: + +```sh +CRABPOT_EXECUTE_ISOLATED=1 npm --prefix ../crabpot run workspace:execute -- --fixture +``` + +## Product and integration rules + +- This is a bridge, not an open Matrix client. Users talk to OpenClaw agents + from the Beeper/OpenClaw bridge instance; do not add outside-world Matrix + semantics unless bridgev2 requires them. +- Every configured OpenClaw agent is a global ghost with the agent's attributes, + including display name and avatar when exposed. +- Each Beeper turn should correspond to one OpenClaw/Beeper stream turn. Do not + emit extra progress messages except approval anchors or bridge-required state. +- Anything OpenClaw exposes through callbacks, hooks, or runtime events must be + mapped and streamed. Pickle cannot recover drops that OpenClaw never exposes. +- Prefer bridgev2 and generated Pickle Go contracts over direct TypeScript + Matrix writes. Direct Matrix client usage is acceptable only where it matches + bridgev2/mautrix bridge practice and keeps the system simpler. +- Keep the code small and direct: no fake layers, no convenience barrel exports, + no duplicated types, no compatibility aliases for unreleased shapes. +- Do not patch the host OpenClaw source while working in this package. Use + OpenClaw as a reference checkout and validate plugin behavior through the + plugin SDK, CLI, and Crabpot. diff --git a/packages/openclaw/LICENSE b/packages/openclaw/LICENSE new file mode 100644 index 0000000..eb86038 --- /dev/null +++ b/packages/openclaw/LICENSE @@ -0,0 +1 @@ +MPL-2.0 diff --git a/packages/openclaw/README.md b/packages/openclaw/README.md new file mode 100644 index 0000000..7692ac2 --- /dev/null +++ b/packages/openclaw/README.md @@ -0,0 +1,80 @@ +# @beeper/openclaw + +Pickle bridge package for exposing OpenClaw sessions in Beeper/Matrix as an OpenClaw-native channel plugin. + +## OpenClaw Plugin Install + +Install the Beeper channel plugin from ClawHub: + +```sh +openclaw plugins install clawhub:@beeper/openclaw +``` + +For a pinned install: + +```sh +openclaw plugins install clawhub:@beeper/openclaw@0.1.0 +``` + +OpenClaw loads the runtime entry from `dist/plugin-entry.mjs` and the lightweight dashboard/setup entry from `dist/setup-entry.mjs`. Configure the channel from the OpenClaw dashboard or with `openclaw channels add beeper`; the setup surface writes `channels.beeper` settings for the bridge runtime. + +## What It Provides + +- Beeper email-code login for existing accounts, with username/password login available when needed. +- Beeper appservice registration for the OpenClaw bridge. +- OpenClaw channel metadata, setup entrypoint, runtime entrypoint, and ClawHub install metadata. +- Pickle bridgev2-style transport for Matrix portals, media, reactions, and receipts. +- Direct in-process OpenClaw plugin runtime access. +- Agent ghosts for OpenClaw agents. +- Beeper contact-list/search and create-DM provisioning for OpenClaw agents. +- Matrix parsing for text, formatted bodies, replies, edits, reactions, redactions, attachments, and thread/relation metadata. +- Native Beeper stream publishing for reasoning, text, tool input/output, approvals, errors, aborts, and final replacement messages. +- OpenClaw-native command discovery and approval surfaces. +- Non-federated Matrix room creation defaults through the generated appservice registration. + +## CLI + +Log in to an existing Beeper account and register the OpenClaw appservice: + +```sh +pickle-openclaw login \ + --config ~/.openclaw/pickle-bridge/config.json \ + --email you@example.com +``` + +The login command requests the email login first, then prompts for the Beeper code. It does not support account registration; users need an existing Beeper account. + +Print the saved Beeper bridge identity: + +```sh +pickle-openclaw whoami --config ~/.openclaw/pickle-bridge/config.json +``` + +The bridge runtime itself is started by OpenClaw when the installed channel plugin is enabled. + +## Programmatic Runtime + +```ts +import { + readConfig, +} from "@beeper/openclaw/config"; +import { + createOpenClawBeeperBridge, +} from "@beeper/openclaw/appservice"; + +const config = await readConfig(); + +const bridge = await createOpenClawBeeperBridge({ + config, +}); + +await bridge.start(); +``` + +For normal use, run `pickle-openclaw login --email you@example.com` and let setup persist the owned Beeper device credentials. + +The runtime uses the in-process OpenClaw plugin context and exposes the Beeper bridge as an OpenClaw channel connector. + +## Protocol Coverage + +`src/protocol-coverage.ts` tracks the OpenClaw channel-turn and Beeper streaming protocol surface. The manifest is tested so future changes can audit which event families are streamed to Beeper, mapped to approvals, intentionally ignored as operational noise, or handled by OpenClaw-native channel APIs. diff --git a/packages/openclaw/openclaw.plugin.json b/packages/openclaw/openclaw.plugin.json new file mode 100644 index 0000000..dd15787 --- /dev/null +++ b/packages/openclaw/openclaw.plugin.json @@ -0,0 +1,197 @@ +{ + "id": "beeper", + "name": "Beeper", + "description": "Chat with your OpenClaw agents on Beeper.", + "activation": { + "onStartup": false + }, + "channels": [ + "beeper" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + }, + "channelConfigs": { + "beeper": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "defaultAccount": { + "type": "string", + "description": "Default Beeper account Matrix user ID for outbound agent DMs when no agent-specific default is set." + }, + "accounts": { + "type": "object", + "description": "Beeper accounts keyed by full Matrix user ID, for example @batuhan:beeper.com.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this Beeper account." + }, + "name": { + "type": "string", + "description": "Display name for this Beeper account in settings and status." + }, + "dataDir": { + "type": "string", + "description": "Beeper bridge state directory for this account." + }, + "serverEnv": { + "type": "string", + "enum": [ + "prod", + "staging", + "dev", + "local" + ], + "default": "prod", + "description": "Beeper server environment to use before login. Changing it after login requires logging out and logging back in." + }, + "asToken": { + "description": "OpenClaw-managed Beeper appservice token.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "provider", + "id" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "env", + "file", + "exec" + ] + }, + "provider": { + "type": "string" + }, + "id": { + "type": "string" + } + } + } + ] + }, + "hsToken": { + "description": "OpenClaw-managed Beeper homeserver token.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "provider", + "id" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "env", + "file", + "exec" + ] + }, + "provider": { + "type": "string" + }, + "id": { + "type": "string" + } + } + } + ] + }, + "bridge": { + "type": "object", + "additionalProperties": false, + "properties": { + "appserviceId": { + "type": "string" + }, + "bridgeId": { + "type": "string" + }, + "homeserver": { + "type": "string" + }, + "homeserverDomain": { + "type": "string" + }, + "matrixDeviceId": { + "type": "string" + }, + "matrixUserId": { + "type": "string" + } + } + } + } + } + }, + "agents": { + "type": "object", + "description": "Per-agent Beeper account assignments. Account IDs are full Matrix user IDs and are not mutually exclusive.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "accountIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Beeper account Matrix user IDs this agent may use." + }, + "defaultAccount": { + "type": "string", + "description": "Preferred Beeper account Matrix user ID for this agent when multiple assigned accounts are available." + } + } + } + } + } + }, + "uiHints": { + "accounts.*.asToken": { + "sensitive": true, + "tags": [ + "hidden" + ] + }, + "accounts.*.hsToken": { + "sensitive": true, + "tags": [ + "hidden" + ] + }, + "accounts.*.serverEnv": { + "help": "Choose before Beeper login. To change it after connecting, log out and log back in." + } + }, + "label": "Beeper", + "description": "Chat with your OpenClaw agents on Beeper.", + "commands": { + "nativeCommandsAutoEnabled": true, + "nativeSkillsAutoEnabled": true + } + } + } +} diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json new file mode 100644 index 0000000..6e8f46f --- /dev/null +++ b/packages/openclaw/package.json @@ -0,0 +1,200 @@ +{ + "name": "@beeper/openclaw", + "version": "0.1.0", + "description": "Beeper Matrix bridge runtime for OpenClaw sessions and agents", + "type": "module", + "homepage": "https://github.com/beeper/pickle#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/beeper/pickle.git", + "directory": "packages/openclaw" + }, + "bugs": { + "url": "https://github.com/beeper/pickle/issues" + }, + "bin": { + "pickle-openclaw": "./dist/cli.mjs" + }, + "main": "./dist/plugin-entry.mjs", + "module": "./dist/plugin-entry.mjs", + "types": "./dist/plugin-entry.d.mts", + "exports": { + ".": { + "types": "./dist/plugin-entry.d.mts", + "import": "./dist/plugin-entry.mjs" + }, + "./approval": { + "types": "./dist/approval.d.mts", + "import": "./dist/approval.mjs" + }, + "./appservice": { + "types": "./dist/appservice.d.mts", + "import": "./dist/appservice.mjs" + }, + "./auth-presence": { + "types": "./dist/auth-presence.d.mts", + "import": "./dist/auth-presence.mjs" + }, + "./bridge-agent": { + "types": "./dist/bridge-agent.d.mts", + "import": "./dist/bridge-agent.mjs" + }, + "./beeper-setup": { + "types": "./dist/beeper-setup.d.mts", + "import": "./dist/beeper-setup.mjs" + }, + "./beeper-channel-runtime": { + "types": "./dist/beeper-channel-runtime.d.mts", + "import": "./dist/beeper-channel-runtime.mjs" + }, + "./cli": { + "types": "./dist/cli.d.mts", + "import": "./dist/cli.mjs" + }, + "./config": { + "types": "./dist/config.d.mts", + "import": "./dist/config.mjs" + }, + "./connector": { + "types": "./dist/connector.d.mts", + "import": "./dist/connector.mjs" + }, + "./matrix-parser": { + "types": "./dist/matrix-parser.d.mts", + "import": "./dist/matrix-parser.mjs" + }, + "./plugin-entry": { + "types": "./dist/plugin-entry.d.mts", + "import": "./dist/plugin-entry.mjs" + }, + "./openclaw-runtime": { + "types": "./dist/openclaw-runtime.d.mts", + "import": "./dist/openclaw-runtime.mjs" + }, + "./protocol-coverage": { + "types": "./dist/protocol-coverage.d.mts", + "import": "./dist/protocol-coverage.mjs" + }, + "./registry": { + "types": "./dist/registry.d.mts", + "import": "./dist/registry.mjs" + }, + "./registration": { + "types": "./dist/registration.d.mts", + "import": "./dist/registration.mjs" + }, + "./rooms": { + "types": "./dist/rooms.d.mts", + "import": "./dist/rooms.mjs" + }, + "./setup": { + "types": "./dist/setup.d.mts", + "import": "./dist/setup.mjs" + }, + "./setup-entry": { + "types": "./dist/setup-entry.d.mts", + "import": "./dist/setup-entry.mjs" + }, + "./types": { + "types": "./dist/types.d.mts", + "import": "./dist/types.mjs" + } + }, + "files": [ + "dist", + "openclaw.plugin.json", + "README.md", + "LICENSE" + ], + "openclaw": { + "extensions": [ + "./src/plugin-entry.ts" + ], + "runtimeExtensions": [ + "./dist/plugin-entry.mjs" + ], + "setupEntry": "./src/setup-entry.ts", + "runtimeSetupEntry": "./dist/setup-entry.mjs", + "channel": { + "id": "beeper", + "label": "Beeper", + "selectionLabel": "Beeper agent DMs", + "detailLabel": "Beeper agent chat", + "docsPath": "/channels/beeper", + "docsLabel": "beeper", + "blurb": "lets you chat with your OpenClaw agents on Beeper.", + "systemImage": "message", + "configuredState": { + "specifier": "./auth-presence", + "exportName": "hasAnyBeeperConfiguredState" + }, + "persistedAuthState": { + "specifier": "./auth-presence", + "exportName": "hasAnyBeeperAuth" + }, + "cliAddOptions": [ + { + "flags": "--server-env ", + "description": "Beeper server environment: prod, staging, dev, or local" + } + ] + }, + "install": { + "clawhubSpec": "clawhub:@beeper/openclaw", + "npmSpec": "@beeper/openclaw", + "defaultChoice": "clawhub", + "minHostVersion": ">=2026.6.2" + }, + "compat": { + "pluginApi": ">=2026.6.2" + }, + "build": { + "openclawVersion": "2026.6.2" + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "node scripts/sync-manifest-schema.mjs && tsdown && node scripts/copy-runtime-assets.mjs", + "clean": "rm -rf dist", + "prepublishOnly": "node ../../scripts/guard-pnpm-publish.mjs", + "sync:schema": "node scripts/sync-manifest-schema.mjs", + "test": "vitest run --coverage", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@beeper/pickle-ag-ui": "workspace:^", + "@beeper/pickle-bridge": "workspace:^", + "@beeper/pickle-state-file": "workspace:^", + "@types/node": "^20.0.0", + "@vitest/coverage-v8": "^4.0.18", + "openclaw": "2026.5.28", + "tsdown": "^0.21.10", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + }, + "peerDependencies": { + "openclaw": ">=2026.6.2" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, + "keywords": [ + "beeper", + "matrix", + "openclaw", + "appservice", + "bridge" + ], + "engines": { + "node": ">=20" + }, + "license": "MPL-2.0" +} diff --git a/packages/openclaw/scripts/copy-runtime-assets.mjs b/packages/openclaw/scripts/copy-runtime-assets.mjs new file mode 100644 index 0000000..a0bf218 --- /dev/null +++ b/packages/openclaw/scripts/copy-runtime-assets.mjs @@ -0,0 +1,28 @@ +import { copyFile, mkdir, stat } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const pickleDist = resolve(packageDir, "../pickle/dist"); +const outputDir = resolve(packageDir, "dist"); + +await mkdir(outputDir, { recursive: true }); + +for (const file of ["pickle.wasm", "wasm_exec.js"]) { + const source = resolve(pickleDist, file); + try { + await stat(source); + } catch { + throw new Error(`Missing ${file}; run pnpm --filter @beeper/pickle build before building @beeper/openclaw`); + } + await copyFile(source, resolve(outputDir, file)); +} + +for (const file of ["setup", "secret-contract"]) { + await copyFile(resolve(outputDir, `${file}.mjs`), resolve(outputDir, `${file}.js`)); + try { + await copyFile(resolve(outputDir, `${file}.mjs.map`), resolve(outputDir, `${file}.js.map`)); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } +} diff --git a/packages/openclaw/scripts/sync-manifest-schema.mjs b/packages/openclaw/scripts/sync-manifest-schema.mjs new file mode 100644 index 0000000..ca7edd9 --- /dev/null +++ b/packages/openclaw/scripts/sync-manifest-schema.mjs @@ -0,0 +1,29 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const schemaPath = resolve(packageDir, "src/beeper-channel-config.schema.json"); +const manifestPath = resolve(packageDir, "openclaw.plugin.json"); + +const schema = JSON.parse(await readFile(schemaPath, "utf8")); +const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + +manifest.configSchema = { + type: "object", + additionalProperties: false, + properties: {}, +}; +delete manifest.uiHints; +manifest.channelConfigs ??= {}; +manifest.channelConfigs.beeper ??= {}; +manifest.channelConfigs.beeper.schema = schema; +manifest.channelConfigs.beeper.uiHints = { + "accounts.*.asToken": { sensitive: true, tags: ["hidden"] }, + "accounts.*.hsToken": { sensitive: true, tags: ["hidden"] }, + "accounts.*.serverEnv": { + help: "Choose before Beeper login. To change it after connecting, log out and log back in.", + }, +}; + +await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); diff --git a/packages/openclaw/src/account-id.ts b/packages/openclaw/src/account-id.ts new file mode 100644 index 0000000..d8a85bc --- /dev/null +++ b/packages/openclaw/src/account-id.ts @@ -0,0 +1,21 @@ +export function normalizeBeeperAccountId(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + if (trimmed.startsWith("@")) return trimmed; + if (trimmed.includes(":")) return `@${trimmed}`; + return undefined; +} + +export function requireBeeperAccountId(value: string | null | undefined): string { + const accountId = normalizeBeeperAccountId(value); + if (!accountId) throw new Error("Beeper account ID must be a full Matrix user ID like @batuhan:beeper.com."); + return accountId; +} + +export function beeperAccountIdFromMatrixUserId(userId: string | undefined): string | undefined { + const accountId = normalizeBeeperAccountId(userId); + if (!accountId || !accountId.includes(":")) return undefined; + const localpart = accountId.startsWith("@") ? accountId.slice(1).split(":")[0] : accountId.split(":")[0]; + const serverName = accountId.split(":").slice(1).join(":"); + return localpart && serverName ? accountId : undefined; +} diff --git a/packages/openclaw/src/approval.test.ts b/packages/openclaw/src/approval.test.ts new file mode 100644 index 0000000..cc285a1 --- /dev/null +++ b/packages/openclaw/src/approval.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; +import { + createBeeperApprovalNotice, + defaultBeeperApprovalChoices, + parseApprovalReactionContent, + parseApprovalResponseContent, + parseToolApprovalResponseChunk, + toOpenClawApprovalResolvePayload, +} from "./approval"; + +describe("OpenClaw approval response parsing", () => { + it("parses Beeper approval reactions into OpenClaw resolve payloads", () => { + const response = parseApprovalReactionContent({ + "m.relates_to": { + event_id: "approval_1", + key: "approval.allow_once", + rel_type: "m.annotation", + }, + toolCallId: "call_1", + }); + expect(response).toEqual({ + approvalId: "approval_1", + approved: true, + approvedAlways: false, + decision: "allow_once", + toolCallId: "call_1", + }); + expect(toOpenClawApprovalResolvePayload("approval_1", response!)).toEqual({ + approvalId: "approval_1", + decision: "approve", + toolCallId: "call_1", + }); + }); + + it("preserves plugin approval kind from native content and reactions", () => { + const reaction = parseApprovalReactionContent({ + approvalKind: "plugin", + "m.relates_to": { + event_id: "plugin:approval_1", + key: "approval.allow_once", + rel_type: "m.annotation", + }, + }); + expect(reaction).toEqual({ + approvalId: "plugin:approval_1", + approvalKind: "plugin", + approved: true, + approvedAlways: false, + decision: "allow_once", + }); + expect(toOpenClawApprovalResolvePayload("plugin:approval_1", reaction!)).toEqual({ + approvalId: "plugin:approval_1", + approvalKind: "plugin", + decision: "approve", + }); + + expect(parseApprovalResponseContent({ + approvalId: "plugin:approval_2", + approvalKind: "plugin", + approved: false, + type: "tool-approval-response", + })).toEqual({ + approvalId: "plugin:approval_2", + approvalKind: "plugin", + approved: false, + approvedAlways: false, + decision: "deny", + }); + }); + + it("does not accept legacy ai-bridge/OpenClaw approval choice keys as reactions", () => { + expect(parseApprovalReactionContent({ + "m.relates_to": { + event_id: "approval_ai_1", + key: "✅", + }, + })).toBeUndefined(); + + expect(parseApprovalReactionContent({ + "m.relates_to": { + event_id: "approval_ai_2", + key: "always_approve", + }, + })).toBeUndefined(); + + expect(parseApprovalReactionContent({ + "m.relates_to": { + event_id: "approval_ai_3", + key: "❌", + }, + })).toBeUndefined(); + }); + + it("builds the same approval notice shape as ai-bridge matrix content", () => { + expect(defaultBeeperApprovalChoices()).toEqual([ + { alias: "✅", key: "approve", label: "Allow once" }, + { alias: "☑️", key: "always_approve", label: "Allow always" }, + { alias: "❌", key: "deny", label: "Deny", style: "danger" }, + ]); + expect(createBeeperApprovalNotice({ + approvalId: "approval_1", + messageId: "msg_1", + toolCallId: "call_1", + toolName: "shell", + })).toMatchObject({ + "com.beeper.ai": { + id: "approval_approval_1", + metadata: { + approval: { id: "approval_1" }, + turn_id: "approval_approval_1", + }, + parts: [{ + approval: { + actions: [ + { decision: "allow-once", id: "allow-once", reactionKey: "approval.allow_once", title: "Allow Once", variant: "secondary" }, + { decision: "allow-always", id: "allow-always", reactionKey: "approval.allow_always", title: "Allow Always", variant: "secondary" }, + { decision: "deny", id: "deny", reactionKey: "approval.deny", title: "Cancel", variant: "destructive" }, + ], + id: "approval_1", + }, + id: "call_1", + name: "shell", + state: "approval-requested", + toolCallId: "call_1", + type: "tool-call", + }], + role: "assistant", + }, + choices: [ + { alias: "✅", key: "approve", label: "Allow once" }, + { alias: "☑️", key: "always_approve", label: "Allow always" }, + { alias: "❌", key: "deny", label: "Deny", style: "danger" }, + ], + id: "approval_1", + messageId: "msg_1", + schema: "com.beeper.ai.approval.v1", + state: "requested", + toolCallId: "call_1", + toolName: "shell", + }); + }); + + it("maps allow-always and deny stream chunks", () => { + expect(parseToolApprovalResponseChunk({ + approvalId: "approval_2", + approved: true, + approvedAlways: true, + toolCallId: "call_2", + type: "tool-approval-response", + })).toEqual({ + approvalId: "approval_2", + approved: true, + approvedAlways: true, + decision: "allow_always", + toolCallId: "call_2", + }); + + const denied = parseToolApprovalResponseChunk({ + approvalId: "approval_3", + approved: false, + toolCallId: "call_3", + type: "tool-approval-response", + }); + expect(denied).toEqual({ + approvalId: "approval_3", + approved: false, + approvedAlways: false, + decision: "deny", + toolCallId: "call_3", + }); + expect(toOpenClawApprovalResolvePayload("approval_3", denied!)).toEqual({ + approvalId: "approval_3", + decision: "deny", + toolCallId: "call_3", + }); + }); + + it("finds approval responses embedded in Beeper stream deltas", () => { + expect(parseApprovalResponseContent({ + "com.beeper.llm.deltas": [ + { + parts: [ + { + approvalId: "approval_4", + approved: true, + decision: "allow-room", + toolCallId: "call_4", + type: "tool-approval-response", + }, + ], + }, + ], + })).toEqual({ + approvalId: "approval_4", + approved: true, + approvedAlways: true, + decision: "allow_room", + toolCallId: "call_4", + }); + }); + + it("accepts AG-UI approval response events and accumulated Beeper AI parts", () => { + expect(parseToolApprovalResponseChunk({ + name: "approval-responded", + type: "CUSTOM", + value: { + approval: { + always: true, + approved: true, + id: "approval_5", + }, + toolCallId: "call_5", + }, + })).toEqual({ + approvalId: "approval_5", + approved: true, + approvedAlways: true, + decision: "allow_always", + toolCallId: "call_5", + }); + + expect(parseApprovalResponseContent({ + "com.beeper.ai": { + parts: [ + { + approval: { + approved: true, + id: "approval_6", + reason: "allow", + }, + state: "approval-responded", + toolCallId: "call_6", + type: "tool-call", + }, + ], + }, + })).toEqual({ + approvalId: "approval_6", + approved: true, + approvedAlways: false, + decision: "allow_once", + toolCallId: "call_6", + }); + }); +}); diff --git a/packages/openclaw/src/approval.ts b/packages/openclaw/src/approval.ts new file mode 100644 index 0000000..7ad44f5 --- /dev/null +++ b/packages/openclaw/src/approval.ts @@ -0,0 +1,345 @@ +export const APPROVAL_ALLOW_ONCE_REACTION = "approval.allow_once"; +export const APPROVAL_ALLOW_ALWAYS_REACTION = "approval.allow_always"; +export const APPROVAL_ALLOW_SESSION_REACTION = "approval.allow_session"; +export const APPROVAL_ALLOW_ROOM_REACTION = "approval.allow_room"; +export const APPROVAL_DENY_REACTION = "approval.deny"; + +export const AI_BRIDGE_APPROVAL_CHOICE_APPROVE = "approve"; +export const AI_BRIDGE_APPROVAL_CHOICE_ALWAYS_APPROVE = "always_approve"; +export const AI_BRIDGE_APPROVAL_CHOICE_DENY = "deny"; + +export interface BeeperApprovalChoice { + alias: string; + key: string; + label: string; + shortcut?: string; + style?: string; +} + +export type ApprovalDecision = "allow_once" | "allow_always" | "allow_session" | "allow_room" | "deny"; +export type OpenClawApprovalKind = "exec" | "plugin"; +export type OpenClawApprovalResolveDecision = "approve" | "approve_always" | "deny"; + +export interface ParsedApprovalResponse { + approvalId?: string; + approvalKind?: OpenClawApprovalKind; + approved: boolean; + approvedAlways: boolean; + decision: ApprovalDecision; + toolCallId?: string; +} + +export interface OpenClawApprovalResolvePayload { + approvalId: string; + approvalKind?: OpenClawApprovalKind; + decision: OpenClawApprovalResolveDecision; + toolCallId?: string; +} + +export function defaultBeeperApprovalChoices(): BeeperApprovalChoice[] { + return [ + { + alias: "✅", + key: AI_BRIDGE_APPROVAL_CHOICE_APPROVE, + label: "Allow once", + }, + { + alias: "☑️", + key: AI_BRIDGE_APPROVAL_CHOICE_ALWAYS_APPROVE, + label: "Allow always", + }, + { + alias: "❌", + key: AI_BRIDGE_APPROVAL_CHOICE_DENY, + label: "Deny", + style: "danger", + }, + ]; +} + +export function defaultBeeperApprovalActions(decisions: readonly ApprovalDecision[] = ["allow_once", "allow_always", "deny"]): Record[] { + return decisions.map((decision) => ({ + decision: decision.replace(/_/gu, "-"), + id: decision.replace(/_/gu, "-"), + reactionKey: approvalReactionKey(decision), + title: approvalActionTitle(decision), + variant: decision === "deny" ? "destructive" : "secondary", + })); +} + +export function parseApprovalReactionKey(key: unknown): ParsedApprovalResponse | undefined { + switch (key) { + case APPROVAL_ALLOW_ONCE_REACTION: + return { approved: true, approvedAlways: false, decision: "allow_once" }; + case APPROVAL_ALLOW_ALWAYS_REACTION: + return { approved: true, approvedAlways: true, decision: "allow_always" }; + case APPROVAL_ALLOW_SESSION_REACTION: + return { approved: true, approvedAlways: false, decision: "allow_session" }; + case APPROVAL_ALLOW_ROOM_REACTION: + return { approved: true, approvedAlways: true, decision: "allow_room" }; + case APPROVAL_DENY_REACTION: + return { approved: false, approvedAlways: false, decision: "deny" }; + default: + return undefined; + } +} + +export function parseApprovalReactionContent(content: unknown): ParsedApprovalResponse | undefined { + const relates = recordValue(content)?.["m.relates_to"]; + const response = parseApprovalReactionKey(recordValue(relates)?.key); + if (!response) return undefined; + const approvalId = stringValue(recordValue(content)?.approvalId) ?? stringValue(recordValue(relates)?.event_id); + const approvalKind = approvalKindValue(recordValue(content)?.approvalKind ?? recordValue(content)?.kind ?? recordValue(relates)?.approvalKind); + const toolCallId = stringValue(recordValue(content)?.toolCallId); + if (approvalId) response.approvalId = approvalId; + if (approvalKind) response.approvalKind = approvalKind; + if (toolCallId) response.toolCallId = toolCallId; + return response; +} + +export function parseToolApprovalResponseChunk(chunk: unknown): ParsedApprovalResponse | undefined { + const record = recordValue(chunk); + if (record?.type === "CUSTOM" && record.name === "approval-responded") return parseApprovalRespondedCustomValue(record.value); + if (record?.type !== "tool-approval-response" || typeof record.approved !== "boolean") return undefined; + const explicitDecision = approvalDecisionValue(record.decision); + const approvedAlways = record.approvedAlways === true || explicitDecision === "allow_always" || explicitDecision === "allow_room"; + const response: ParsedApprovalResponse = { + approved: record.approved, + approvedAlways, + decision: record.approved ? explicitDecision ?? (approvedAlways ? "allow_always" : "allow_once") : "deny", + }; + const approvalId = stringValue(record.approvalId); + const approvalKind = approvalKindValue(record.approvalKind ?? record.kind); + const toolCallId = stringValue(record.toolCallId); + if (approvalId) response.approvalId = approvalId; + if (approvalKind) response.approvalKind = approvalKind; + if (toolCallId) response.toolCallId = toolCallId; + return response; +} + +export function parseApprovalResponseContent(content: unknown): ParsedApprovalResponse | undefined { + return parseToolApprovalResponseChunk(content) + ?? parseApprovalResponseFromDeltas(content) + ?? parseApprovalResponseFromAIMessage(content); +} + +export function toOpenClawApprovalResolvePayload( + approvalId: string, + response: ParsedApprovalResponse +): OpenClawApprovalResolvePayload { + const payload: OpenClawApprovalResolvePayload = { + approvalId, + ...(response.approvalKind ? { approvalKind: response.approvalKind } : {}), + decision: response.approved ? (response.approvedAlways ? "approve_always" : "approve") : "deny", + }; + if (response.toolCallId) payload.toolCallId = response.toolCallId; + return payload; +} + +export function approvalChoicesAsAny(choices: readonly BeeperApprovalChoice[] = defaultBeeperApprovalChoices()): Record[] { + return choices.map((choice) => stripUndefined({ + alias: choice.alias, + key: choice.key, + label: choice.label, + shortcut: choice.shortcut, + style: choice.style, + })); +} + +export function createBeeperApprovalNotice(params: { + approvalId: string; + messageId: string; + body?: string; + input?: Record; + state?: "approval-requested" | "approval-responded"; + approved?: boolean; + decision?: string; + expiresAtMs?: number; + toolCallId?: string; + toolName?: string; + choices?: readonly BeeperApprovalChoice[]; +}): Record { + const toolCallId = params.toolCallId ?? params.approvalId; + const toolName = params.toolName ?? "OpenClaw tool"; + const approvalActions = defaultBeeperApprovalActions(); + return stripUndefined({ + "com.beeper.ai": { + id: `approval_${params.approvalId}`, + metadata: { + approval: stripUndefined({ + expiresAt: params.expiresAtMs, + id: params.approvalId, + }), + turn_id: `approval_${params.approvalId}`, + }, + parts: [{ + approval: stripUndefined({ + actions: approvalActions, + approved: params.approved, + decision: params.decision, + expiresAtMs: params.expiresAtMs, + id: params.approvalId, + }), + id: toolCallId, + input: stripUndefined({ + ...(params.input ?? {}), + approvalActions, + ...(params.expiresAtMs !== undefined ? { expiresAtMs: params.expiresAtMs } : {}), + }), + name: toolName, + state: params.state ?? "approval-requested", + toolCallId, + type: "tool-call", + }], + role: "assistant", + }, + choices: approvalChoicesAsAny(params.choices), + id: params.approvalId, + messageId: params.messageId, + schema: "com.beeper.ai.approval.v1", + state: "requested", + toolCallId, + toolName, + }); +} + +function parseApprovalResponseFromDeltas(content: unknown): ParsedApprovalResponse | undefined { + const deltas = recordValue(content)?.["com.beeper.llm.deltas"]; + if (!Array.isArray(deltas)) return undefined; + for (const delta of deltas) { + const parts = recordValue(delta)?.parts; + if (!Array.isArray(parts)) continue; + for (const part of parts) { + const response = parseToolApprovalResponseChunk(part); + if (response) return response; + } + } + return undefined; +} + +function parseApprovalResponseFromAIMessage(content: unknown): ParsedApprovalResponse | undefined { + const parts = recordValue(recordValue(content)?.["com.beeper.ai"])?.parts; + if (!Array.isArray(parts)) return undefined; + for (const part of parts) { + const record = recordValue(part); + const approval = recordValue(record?.approval); + if (!record || !approval || typeof approval.approved !== "boolean") continue; + const explicitDecision = approvalDecisionValue(approval.reason ?? approval.decision ?? record.decision); + const approvedAlways = approval.always === true || record.approvedAlways === true || explicitDecision === "allow_always" || explicitDecision === "allow_room"; + const response: ParsedApprovalResponse = { + approved: approval.approved, + approvedAlways, + decision: approval.approved ? explicitDecision ?? (approvedAlways ? "allow_always" : "allow_once") : "deny", + }; + const approvalId = stringValue(approval.id) ?? stringValue(record.approvalId); + const approvalKind = approvalKindValue(approval.kind ?? approval.approvalKind ?? record.approvalKind ?? record.kind); + const toolCallId = stringValue(record.toolCallId); + if (approvalId) response.approvalId = approvalId; + if (approvalKind) response.approvalKind = approvalKind; + if (toolCallId) response.toolCallId = toolCallId; + return response; + } + return undefined; +} + +function parseApprovalRespondedCustomValue(value: unknown): ParsedApprovalResponse | undefined { + const record = recordValue(value); + const approval = recordValue(record?.approval); + const approved = approval?.approved; + if (!record || !approval || typeof approved !== "boolean") return undefined; + const explicitDecision = approvalDecisionValue(approval.reason ?? approval.decision ?? record.decision); + const approvedAlways = approval.always === true || record.approvedAlways === true || explicitDecision === "allow_always" || explicitDecision === "allow_room"; + const response: ParsedApprovalResponse = { + approved, + approvedAlways, + decision: approved ? explicitDecision ?? (approvedAlways ? "allow_always" : "allow_once") : "deny", + }; + const approvalId = stringValue(approval.id) ?? stringValue(record.approvalId); + const approvalKind = approvalKindValue(approval.kind ?? approval.approvalKind ?? record.approvalKind ?? record.kind); + const toolCallId = stringValue(record.toolCallId); + if (approvalId) response.approvalId = approvalId; + if (approvalKind) response.approvalKind = approvalKind; + if (toolCallId) response.toolCallId = toolCallId; + return response; +} + +function approvalDecisionValue(value: unknown): ApprovalDecision | undefined { + switch (value) { + case "allow_once": + case "allow_always": + case "allow_session": + case "allow_room": + case "deny": + return value; + case "allow-once": + return "allow_once"; + case "allow-always": + return "allow_always"; + case "allow-session": + return "allow_session"; + case "allow-room": + return "allow_room"; + case "allow": + return "allow_once"; + case "always": + return "allow_always"; + default: + return undefined; + } +} + +function approvalReactionKey(decision: ApprovalDecision): string { + switch (decision) { + case "allow_once": + return APPROVAL_ALLOW_ONCE_REACTION; + case "allow_always": + return APPROVAL_ALLOW_ALWAYS_REACTION; + case "allow_session": + return APPROVAL_ALLOW_SESSION_REACTION; + case "allow_room": + return APPROVAL_ALLOW_ROOM_REACTION; + case "deny": + return APPROVAL_DENY_REACTION; + } +} + +function approvalActionTitle(decision: ApprovalDecision): string { + switch (decision) { + case "allow_once": + return "Allow Once"; + case "allow_always": + return "Allow Always"; + case "allow_session": + return "Allow This Session"; + case "allow_room": + return "Allow This Room"; + case "deny": + return "Cancel"; + } +} + +export function approvalKindForId(approvalId: string | undefined): OpenClawApprovalKind | undefined { + if (!approvalId) return undefined; + if (approvalId.startsWith("plugin:") || approvalId.startsWith("plugin_") || approvalId.startsWith("plugin.")) return "plugin"; + if (approvalId.startsWith("exec:") || approvalId.startsWith("exec_") || approvalId.startsWith("exec.")) return "exec"; + return undefined; +} + +function approvalKindValue(value: unknown): OpenClawApprovalKind | undefined { + if (value === "plugin" || value === "plugin-approval" || value === "plugin.approval") return "plugin"; + if (value === "exec" || value === "execution" || value === "exec-approval" || value === "exec.approval") return "exec"; + return undefined; +} + +function recordValue(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function stripUndefined>(record: T): T { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T; +} diff --git a/packages/openclaw/src/appservice.test.ts b/packages/openclaw/src/appservice.test.ts new file mode 100644 index 0000000..ed72cc2 --- /dev/null +++ b/packages/openclaw/src/appservice.test.ts @@ -0,0 +1,147 @@ +import type { CreateNodeBeeperBridgeOptions, PickleBridge } from "@beeper/pickle-bridge/node"; +import { describe, expect, it, vi } from "vitest"; +import { createDefaultConfig } from "./config"; +import { createOpenClawBeeperBridge, startOpenClawBeeperBridge } from "./appservice"; +import { OpenClawBridgeRegistry } from "./registry"; + +describe("OpenClaw Beeper appservice runtime", () => { + it("creates a Pickle Beeper bridge with the OpenClaw connector defaults", async () => { + const bridge = fakeBridge(); + const bridgeFactory = vi.fn(async (_options: CreateNodeBeeperBridgeOptions) => bridge); + const config = createDefaultConfig({ + dataDir: "/tmp/openclaw", + asToken: "as-token", + homeserver: "https://matrix.beeper-staging.com", + homeserverDomain: "beeper.local", + hsToken: "hs-token", + matrixUserId: "@batuhan:beeper-staging.com", + serverEnv: "staging", + }); + + await expect(createOpenClawBeeperBridge({ + bridgeFactory, + config, + dataDir: "/tmp/openclaw-data", + getOnly: true, + })).resolves.toBe(bridge); + + expect(bridgeFactory).toHaveBeenCalledWith(expect.objectContaining({ + baseDomain: "beeper-staging.com", + bridge: "sh-openclaw", + bridgeManagerPostState: true, + bridgeType: "openclaw", + connector: expect.objectContaining({ + config, + }), + dataDir: "/tmp/openclaw-data", + getOnly: true, + homeserverDomain: "beeper.local", + ownerUserId: "@batuhan:beeper-staging.com", + })); + expect(bridgeFactory.mock.calls[0]?.[0]).not.toHaveProperty("address"); + }); + + it("starts the created bridge", async () => { + const bridge = fakeBridge(); + await expect(startOpenClawBeeperBridge({ + bridgeFactory: async () => bridge, + config: createDefaultConfig({ + asToken: "as-token", + dataDir: "/tmp/openclaw", + homeserver: "https://matrix.beeper.com", + hsToken: "hs-token", + }), + })).resolves.toBe(bridge); + expect(bridge.start).toHaveBeenCalledOnce(); + }); + + it("marks the bridge running after the appservice starts", async () => { + const bridge = fakeBridge(); + const config = createDefaultConfig({ + appserviceId: "sh-openclaw-device", + asToken: "as-token", + bridgeId: "sh-openclaw-device", + dataDir: "/tmp/openclaw", + homeserver: "https://matrix.beeper-staging.com", + hsToken: "hs-token", + matrixUserId: "@batuhan:beeper-staging.com", + serverEnv: "staging", + }); + + await expect(startOpenClawBeeperBridge({ + bridgeFactory: async () => bridge, + config, + })).resolves.toBe(bridge); + + expect(bridge.start).toHaveBeenCalledOnce(); + expect(bridge.setBridgeState).toHaveBeenCalledWith("running"); + }); + + it("starts from persisted appservice config without re-registering", async () => { + const bridge = fakeBridge(); + const bridgeFactory = vi.fn(async (_options: CreateNodeBeeperBridgeOptions) => bridge); + const config = createDefaultConfig({ + appserviceId: "sh-openclaw-device", + asToken: "as-token", + dataDir: "/tmp/openclaw", + homeserver: "https://matrix.beeper-staging.com", + homeserverDomain: "beeper.local", + hsToken: "hs-token", + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper-staging.com", + }); + + await expect(startOpenClawBeeperBridge({ + bridgeFactory, + config, + })).resolves.toBe(bridge); + + expect(bridgeFactory).toHaveBeenCalledWith(expect.objectContaining({ + matrix: expect.objectContaining({ + appservice: expect.objectContaining({ + homeserver: "https://matrix.beeper-staging.com", + homeserverDomain: "beeper.local", + registration: expect.objectContaining({ + asToken: "as-token", + hsToken: "hs-token", + id: "sh-openclaw-device", + senderLocalpart: "sh-openclaw-devicebot", + url: "websocket", + }), + }), + homeserver: "https://matrix.beeper-staging.com", + }), + })); + expect(bridgeFactory.mock.calls[0]?.[0].matrix).not.toHaveProperty("account"); + expect(bridgeFactory.mock.calls[0]?.[0].matrix).not.toHaveProperty("token"); + expect(bridgeFactory.mock.calls[0]?.[0]).toMatchObject({ + ownerUserId: "@batuhan:beeper-staging.com", + }); + }); + + it("does not run historical imports during bridge startup", async () => { + const bridge = fakeBridge(); + await expect(startOpenClawBeeperBridge({ + bridgeFactory: async () => bridge, + config: createDefaultConfig({ + asToken: "as-token", + dataDir: "/tmp/openclaw", + homeserver: "https://matrix.beeper.com", + hsToken: "hs-token", + }), + })).resolves.toBe(bridge); + + expect(bridge.start).toHaveBeenCalledOnce(); + expect(bridge.createPortal).not.toHaveBeenCalled(); + }); +}); + +function fakeBridge(options: { registry?: OpenClawBridgeRegistry } = {}): PickleBridge { + return { + connector: options.registry ? { registry: options.registry } : undefined, + createPortal: vi.fn(), + setBridgeState: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + } as unknown as PickleBridge; +} diff --git a/packages/openclaw/src/appservice.ts b/packages/openclaw/src/appservice.ts new file mode 100644 index 0000000..c99c3a9 --- /dev/null +++ b/packages/openclaw/src/appservice.ts @@ -0,0 +1,98 @@ +import { + createBeeperBridge, + type CreateNodeBeeperBridgeOptions, + type PickleBridge, +} from "@beeper/pickle-bridge/node"; +import type { MatrixAppserviceInitOptions, MatrixAppserviceRegistration } from "@beeper/pickle-bridge/beeper"; +import { beeperBaseDomain } from "./beeper-setup"; +import { DEFAULT_BEEPER_BRIDGE_TYPE } from "./ids"; +import { createOpenClawConnector, type OpenClawConnectorOptions } from "./connector"; +import { createAppserviceRegistration } from "./registration"; +import type { OpenClawBridgeConfig } from "./types"; + +export interface CreateOpenClawBeeperBridgeOptions extends OpenClawConnectorOptions { + bridge?: string; + bridgeFactory?: (options: CreateNodeBeeperBridgeOptions) => Promise; + bridgeType?: string; + connector?: CreateNodeBeeperBridgeOptions["connector"]; + dataDir?: string; + getOnly?: boolean; + log?: CreateNodeBeeperBridgeOptions["log"]; + matrix?: CreateNodeBeeperBridgeOptions["matrix"]; + store?: CreateNodeBeeperBridgeOptions["store"]; +} + +export async function createOpenClawBeeperBridge(options: CreateOpenClawBeeperBridgeOptions): Promise { + const config = options.config; + const connector = options.connector ?? createOpenClawConnector(connectorOptions(options)); + const bridgeOptions: CreateNodeBeeperBridgeOptions = { + bridge: options.bridge ?? config?.bridgeId ?? config?.appserviceId ?? "sh-openclaw", + bridgeType: options.bridgeType ?? DEFAULT_BEEPER_BRIDGE_TYPE, + connector, + }; + if (config?.matrixUserId !== undefined) bridgeOptions.ownerUserId = config.matrixUserId; + const baseDomain = beeperBaseDomain(config?.serverEnv === "prod" ? "production" : config?.serverEnv); + if (baseDomain !== undefined) bridgeOptions.baseDomain = baseDomain; + bridgeOptions.bridgeManagerPostState = true; + if (config?.homeserverDomain !== undefined) bridgeOptions.homeserverDomain = config.homeserverDomain; + if (options.dataDir !== undefined) bridgeOptions.dataDir = options.dataDir; + if (options.getOnly !== undefined) bridgeOptions.getOnly = options.getOnly; + if (options.log !== undefined) bridgeOptions.log = options.log; + const matrix = matrixOptionsFromConfig(config, options.matrix); + if (matrix !== undefined) bridgeOptions.matrix = matrix; + if (options.store !== undefined) bridgeOptions.store = options.store; + const bridgeFactory = options.bridgeFactory ?? createBeeperBridge; + return bridgeFactory(bridgeOptions); +} + +export async function startOpenClawBeeperBridge(options: CreateOpenClawBeeperBridgeOptions): Promise { + const bridge = await createOpenClawBeeperBridge(options); + await bridge.start(); + await bridge.setBridgeState("running"); + return bridge; +} + +function connectorOptions(options: CreateOpenClawBeeperBridgeOptions): OpenClawConnectorOptions { + const output: OpenClawConnectorOptions = {}; + if (options.config !== undefined) output.config = options.config; + if (options.onActivity !== undefined) output.onActivity = options.onActivity; + if (options.registry !== undefined) output.registry = options.registry; + if (options.runtimeFactory !== undefined) output.runtimeFactory = options.runtimeFactory; + if (options.runtime !== undefined) output.runtime = options.runtime; + return output; +} + +function matrixOptionsFromConfig( + config: OpenClawBridgeConfig | undefined, + input: CreateNodeBeeperBridgeOptions["matrix"] | undefined +): CreateNodeBeeperBridgeOptions["matrix"] | undefined { + const appservice = config && hasPersistedAppservice(config) ? appserviceInitFromConfig(config) : undefined; + if (!appservice && input === undefined) return undefined; + return { + ...input, + ...(appservice && input?.appservice === undefined ? { appservice } : {}), + ...(appservice && config?.matrixDeviceId && input?.deviceId === undefined ? { deviceId: config.matrixDeviceId } : {}), + ...(config?.homeserver && input?.homeserver === undefined ? { homeserver: config.homeserver } : {}), + }; +} + +function hasPersistedAppservice(config: OpenClawBridgeConfig): boolean { + return Boolean(config.asToken && config.hsToken && config.homeserver); +} + +function appserviceInitFromConfig(config: OpenClawBridgeConfig): MatrixAppserviceInitOptions { + const registration = createAppserviceRegistration(config); + return { + homeserver: config.homeserver!, + ...(config.homeserverDomain !== undefined ? { homeserverDomain: config.homeserverDomain } : {}), + registration: { + asToken: registration.as_token, + hsToken: registration.hs_token, + id: registration.id, + namespaces: registration.namespaces, + rateLimited: registration.rate_limited, + senderLocalpart: registration.sender_localpart, + url: registration.url, + } satisfies MatrixAppserviceRegistration, + }; +} diff --git a/packages/openclaw/src/auth-presence.test.ts b/packages/openclaw/src/auth-presence.test.ts new file mode 100644 index 0000000..d71a438 --- /dev/null +++ b/packages/openclaw/src/auth-presence.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { hasAnyBeeperAuth, hasAnyBeeperConfiguredState } from "./auth-presence"; + +describe("Beeper auth presence metadata probe", () => { + it("detects configured Beeper accounts", () => { + const configuredAccount = { + asToken: "as", + enabled: true, + hsToken: "hs", + bridge: { + homeserver: "https://matrix.example", + matrixDeviceId: "DEV", + matrixUserId: "@alice:beeper.com", + }, + }; + + expect(hasAnyBeeperConfiguredState({ + cfg: { + channels: { + beeper: { + accounts: { + "@alice:beeper.com": configuredAccount, + }, + }, + }, + }, + })).toBe(true); + }); + + it("does not treat partial bridge config as persisted auth", () => { + expect(hasAnyBeeperAuth({ + channels: { + beeper: { + accounts: { + "@alice:beeper.com": { + enabled: true, + bridge: { + homeserver: "https://matrix.example", + matrixUserId: "@alice:beeper.com", + }, + }, + }, + }, + }, + })).toBe(false); + }); +}); diff --git a/packages/openclaw/src/auth-presence.ts b/packages/openclaw/src/auth-presence.ts new file mode 100644 index 0000000..f338870 --- /dev/null +++ b/packages/openclaw/src/auth-presence.ts @@ -0,0 +1,52 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { SecretInput } from "openclaw/plugin-sdk/secret-input-runtime"; +import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime"; + +type BeeperAuthPresenceParams = + | { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + } + | OpenClawConfig; + +type BeeperAuthAccount = { + asToken?: SecretInput; + bridge?: { + homeserver?: string; + matrixDeviceId?: string; + matrixUserId?: string; + }; + enabled?: boolean; + hsToken?: SecretInput; +}; + +type BeeperAuthChannel = { + accounts?: Record; +}; + +export function hasAnyBeeperAuth( + params: BeeperAuthPresenceParams, +): boolean { + const cfg = params && typeof params === "object" && "cfg" in params ? params.cfg : params; + const channel = cfg.channels?.beeper as BeeperAuthChannel | undefined; + if (!channel) return false; + return listBeeperAuthAccounts(channel).some((account) => hasBeeperAuthAccount(account, cfg)); +} + +export const hasAnyBeeperConfiguredState = hasAnyBeeperAuth; + +function listBeeperAuthAccounts(channel: BeeperAuthChannel): readonly BeeperAuthAccount[] { + return Object.values(channel.accounts ?? {}).filter((account): account is BeeperAuthAccount => Boolean(account)); +} + +function hasBeeperAuthAccount(account: BeeperAuthAccount, cfg: OpenClawConfig): boolean { + const bridge = account.bridge; + return Boolean( + account.enabled !== false && + hasConfiguredSecretInput(account.asToken, cfg.secrets?.defaults) && + hasConfiguredSecretInput(account.hsToken, cfg.secrets?.defaults) && + bridge?.homeserver && + bridge.matrixDeviceId && + bridge.matrixUserId + ); +} diff --git a/packages/openclaw/src/beeper-channel-config.schema.json b/packages/openclaw/src/beeper-channel-config.schema.json new file mode 100644 index 0000000..3ec9170 --- /dev/null +++ b/packages/openclaw/src/beeper-channel-config.schema.json @@ -0,0 +1,101 @@ +{ + "type": "object", + "additionalProperties": false, + "properties": { + "defaultAccount": { + "type": "string", + "description": "Default Beeper account Matrix user ID for outbound agent DMs when no agent-specific default is set." + }, + "accounts": { + "type": "object", + "description": "Beeper accounts keyed by full Matrix user ID, for example @batuhan:beeper.com.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this Beeper account." + }, + "name": { + "type": "string", + "description": "Display name for this Beeper account in settings and status." + }, + "dataDir": { + "type": "string", + "description": "Beeper bridge state directory for this account." + }, + "serverEnv": { + "type": "string", + "enum": ["prod", "staging", "dev", "local"], + "default": "prod", + "description": "Beeper server environment to use before login. Changing it after login requires logging out and logging back in." + }, + "asToken": { + "description": "OpenClaw-managed Beeper appservice token.", + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "required": ["source", "provider", "id"], + "properties": { + "source": { "type": "string", "enum": ["env", "file", "exec"] }, + "provider": { "type": "string" }, + "id": { "type": "string" } + } + } + ] + }, + "hsToken": { + "description": "OpenClaw-managed Beeper homeserver token.", + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "required": ["source", "provider", "id"], + "properties": { + "source": { "type": "string", "enum": ["env", "file", "exec"] }, + "provider": { "type": "string" }, + "id": { "type": "string" } + } + } + ] + }, + "bridge": { + "type": "object", + "additionalProperties": false, + "properties": { + "appserviceId": { "type": "string" }, + "bridgeId": { "type": "string" }, + "homeserver": { "type": "string" }, + "homeserverDomain": { "type": "string" }, + "matrixDeviceId": { "type": "string" }, + "matrixUserId": { "type": "string" } + } + } + } + } + }, + "agents": { + "type": "object", + "description": "Per-agent Beeper account assignments. Account IDs are full Matrix user IDs and are not mutually exclusive.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "accountIds": { + "type": "array", + "items": { "type": "string" }, + "description": "Beeper account Matrix user IDs this agent may use." + }, + "defaultAccount": { + "type": "string", + "description": "Preferred Beeper account Matrix user ID for this agent when multiple assigned accounts are available." + } + } + } + } + } +} diff --git a/packages/openclaw/src/beeper-channel-runtime.test.ts b/packages/openclaw/src/beeper-channel-runtime.test.ts new file mode 100644 index 0000000..7d31ae4 --- /dev/null +++ b/packages/openclaw/src/beeper-channel-runtime.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it, vi } from "vitest"; +import { + BeeperChannelRuntime, + getBeeperChannelRuntimeForHost, + requireBeeperChannelRuntimeForHost, + setBeeperChannelRuntimeForHost, +} from "./beeper-channel-runtime"; +import { BeeperTurnStream } from "@beeper/pickle-bridge/beeper-stream"; + +function createClient() { + return { + appservice: { + sendMessage: vi.fn(async () => ({ eventId: "$as" })), + }, + media: { + upload: vi.fn(async () => ({ contentUri: "mxc://example/media", raw: {} })), + }, + messages: { + edit: vi.fn(async () => ({ eventId: "$edit" })), + redact: vi.fn(async () => undefined), + send: vi.fn(async () => ({ eventId: "$send" })), + sendMedia: vi.fn(async () => ({ eventId: "$media" })), + }, + reactions: { + redact: vi.fn(async () => undefined), + send: vi.fn(async () => ({ eventId: "$reaction" })), + }, + typing: { + set: vi.fn(async () => undefined), + }, + }; +} + +function createStreamingClient() { + return { + ...createClient(), + beeper: { + aiRunStreams: { + appendEvent: vi.fn(), + error: vi.fn(), + finish: vi.fn(), + start: vi.fn(async ({ agentId, agentName, runId }: { agentId?: string; agentName?: string; runId: string }) => ({ + body: "...", + descriptor: { type: "com.beeper.llm", user_id: "@codex:example" }, + eventId: "$stream", + events: [ + { runId, threadId: runId, type: "RUN_STARTED" }, + { messageId: `msg-${runId}`, role: "assistant", type: "TEXT_MESSAGE_START" }, + ], + finalAIMessage: {}, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: { + agent: { displayName: agentName, id: agentId }, + runId, + status: { state: "streaming" }, + threadId: runId, + }, + raw: {}, + roomId: "!room", + runId, + threadId: runId, + })), + }, + aiRuns: { + begin: vi.fn(async ({ agentId, agentName, runId }: { agentId?: string; agentName?: string; runId: string }) => ({ + body: "...", + events: [ + { runId, threadId: runId, type: "RUN_STARTED" }, + { messageId: runId, role: "assistant", type: "TEXT_MESSAGE_START" }, + ], + finalAIMessage: {}, + initialAIMessage: { + id: runId, + metadata: { turn_id: runId }, + parts: [], + role: "assistant", + }, + metadata: { + agent: { displayName: agentName, id: agentId }, + runId, + status: { state: "streaming" }, + threadId: runId, + }, + messageId: runId, + runId, + threadId: runId, + })), + appendEvent: vi.fn(), + error: vi.fn(), + finish: vi.fn(), + }, + streams: { + finalizeMessage: vi.fn(), + publishPart: vi.fn(async () => undefined), + startMessage: vi.fn(async () => ({ + descriptor: { type: "com.beeper.llm", user_id: "@codex:example" }, + eventId: "$stream", + roomId: "!room", + })), + }, + }, + }; +} + +function createBridge(client: ReturnType | ReturnType, queued: unknown[] = []) { + return { + createBeeperTurnStream: vi.fn((options) => new BeeperTurnStream({ + ...options, + client: client as never, + })), + flushRemoteEvents: vi.fn(async () => undefined), + getPortalByMXID: vi.fn(() => ({ portalKey: { id: "conversation:one", receiver: "openclaw:plugin" } })), + queueRemoteEvent: vi.fn((_login: unknown, event: unknown) => queued.push(event)), + uploadMedia: vi.fn((options: Parameters["media"]["upload"]>[0]) => client.media.upload(options)), + }; +} + +describe("BeeperChannelRuntime", () => { + it("requires bridge portal routing for outbound message operations", async () => { + const client = createClient(); + const runtime = new BeeperChannelRuntime({ + getAgents: () => [{ id: "codex", name: "Codex" }], + }); + + expect(runtime.listAgents()).toEqual([{ id: "codex", name: "Codex" }]); + await expect(runtime.sendText({ roomId: "!room", text: "hi" })).rejects.toThrow("requires a Pickle bridge"); + expect(client.messages.send).not.toHaveBeenCalled(); + }); + + it("queues Matrix event ids as bundled bridge update targets", async () => { + const client = createClient(); + const queued: unknown[] = []; + const bridge = createBridge(client, queued); + const runtime = new BeeperChannelRuntime({ + bridge: bridge as never, + login: { id: "openclaw:plugin" }, + }); + + await runtime.edit({ eventId: "$matrix", roomId: "!room", text: "edit" }); + + const event = queued[0] as { + getTargetDBMessage: () => Array<{ id: string; mxid: string; partId: string }>; + getTargetMessage: () => string; + getType: () => string; + }; + expect(event.getType()).toBe("edit"); + expect(event.getTargetMessage()).toBe("$matrix"); + expect(event.getTargetDBMessage()).toEqual([{ id: "$matrix", mxid: "$matrix", partId: "0" }]); + expect(client.messages.edit).not.toHaveBeenCalled(); + }); + + it("prefers bridge remote events for bound portal message operations", async () => { + const client = createClient(); + const queued: unknown[] = []; + const bridge = createBridge(client, queued); + const runtime = new BeeperChannelRuntime({ + bridge: bridge as never, + getBindingByRoom: () => ({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example", + id: "binding", + roomId: "!room", + sessionKey: "session_1", + updatedAt: 1, + }), + login: { id: "openclaw:plugin" }, + userId: "@bot:example", + }); + + const sent = await runtime.sendText({ roomId: "!room", text: "from agent" }); + expect(sent.eventId).toMatch(/^openclaw:message:/u); + expect(client.appservice.sendMessage).not.toHaveBeenCalled(); + expect(bridge.queueRemoteEvent).toHaveBeenCalledOnce(); + expect(bridge.flushRemoteEvents).toHaveBeenCalledOnce(); + const messageEvent = queued[0] as { + convertMessage: () => Promise<{ parts: Array<{ content: Record }> }>; + getID: () => string; + getSender: () => { sender: string }; + getType: () => string; + }; + expect(messageEvent.getType()).toBe("message"); + expect(messageEvent.getSender()).toEqual({ isFromMe: true, sender: "@codex:example" }); + expect((await messageEvent.convertMessage()).parts[0]?.content).toEqual({ body: "from agent", msgtype: "m.text" }); + + await runtime.sendText({ replyToId: "$reply", roomId: "!room", text: "threaded", threadRoot: "$thread" }); + const threadedTextEvent = queued[1] as { + convertMessage: () => Promise<{ parts: Array<{ content: Record }> }>; + }; + expect((await threadedTextEvent.convertMessage()).parts[0]?.content["m.relates_to"]).toEqual({ + "m.in_reply_to": { event_id: "$reply" }, + "m.thread": { event_id: "$thread" }, + }); + + await runtime.sendMedia({ bytes: new Uint8Array([1]), caption: "cap", filename: "a.txt", replyToId: "$reply", roomId: "!room", threadRoot: "$thread" }); + expect(bridge.uploadMedia).toHaveBeenCalledWith({ + bytes: new Uint8Array([1]), + filename: "a.txt", + }); + expect(client.media.upload).toHaveBeenCalledWith({ + bytes: new Uint8Array([1]), + filename: "a.txt", + }); + const mediaEvent = queued[2] as { + convertMessage: () => Promise<{ parts: Array<{ content: Record }> }>; + }; + expect((await mediaEvent.convertMessage()).parts[0]?.content["m.relates_to"]).toEqual({ + "m.in_reply_to": { event_id: "$reply" }, + "m.thread": { event_id: "$thread" }, + }); + + await runtime.edit({ eventId: sent.eventId, roomId: "!room", text: "edited" }); + await runtime.react({ emoji: "+1", eventId: sent.eventId, roomId: "!room" }); + await runtime.removeReaction({ emoji: "+1", eventId: sent.eventId, roomId: "!room" }); + await runtime.redact({ eventId: sent.eventId, roomId: "!room" }); + await runtime.typing({ roomId: "!room", timeoutMs: 5000 }); + await runtime.readReceipt({ eventId: sent.eventId, roomId: "!room" }); + await runtime.deliveryReceipt({ eventId: sent.eventId, roomId: "!room" }); + await runtime.markUnread({ eventId: sent.eventId, roomId: "!room", unread: true }); + + expect(queued.slice(1).map((event) => (event as { getType: () => string }).getType())).toEqual([ + "message", + "message", + "edit", + "reaction", + "reaction_remove", + "message_remove", + "typing", + "read_receipt", + "delivery_receipt", + "mark_unread", + ]); + expect(client.messages.edit).not.toHaveBeenCalled(); + expect(client.reactions.send).not.toHaveBeenCalled(); + expect(client.messages.redact).not.toHaveBeenCalled(); + expect(client.typing.set).not.toHaveBeenCalled(); + }); + + it("routes OpenClaw session targets through their bound Beeper portal", async () => { + const client = createClient(); + const queued: unknown[] = []; + const bridge = createBridge(client, queued); + bridge.getPortalByMXID.mockImplementation((roomId: string) => + roomId === "!room" + ? { portalKey: { id: "conversation:one", receiver: "openclaw:plugin" } } + : undefined + ); + const runtime = new BeeperChannelRuntime({ + bridge: bridge as never, + getBindingBySessionKey: (sessionKey) => + sessionKey === "agent:main:beeper:abc" + ? { + agentId: "main", + createdAt: 1, + ghostUserId: "@main:example", + id: "binding", + roomId: "!room", + sessionKey, + updatedAt: 1, + } + : undefined, + login: { id: "openclaw:plugin" }, + userId: "@bot:example", + }); + + await runtime.sendText({ roomId: "main:beeper:abc", text: "from message tool" }); + + expect(bridge.getPortalByMXID).toHaveBeenCalledWith("!room"); + const messageEvent = queued[0] as { + getSender: () => { sender: string }; + }; + expect(messageEvent.getSender()).toEqual({ isFromMe: true, sender: "@main:example" }); + }); + + it("starts native streams as the bound assistant ghost", async () => { + const client = createStreamingClient(); + const bridge = createBridge(client); + const runtime = new BeeperChannelRuntime({ + bridge: bridge as never, + getAgents: () => [{ + agentId: "codex", + displayName: "Codex", + ghostUserId: "@codex:example", + }], + getBindingByRoom: () => ({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example", + id: "binding", + roomId: "!room", + sessionKey: "agent:codex:desktop", + updatedAt: 1, + }), + login: { id: "openclaw:plugin" }, + userId: "@bot:example", + }); + + const stream = runtime.createStreamPublisher({ + agentId: "codex", + roomId: "!room", + runId: "run_1", + sessionKey: "agent:codex:desktop", + }); + await stream.start(); + + expect(client.beeper.aiRunStreams.start).toHaveBeenCalledWith(expect.objectContaining({ + agentId: "codex", + agentName: "Codex", + runId: "run_1", + })); + expect(client.beeper.aiRunStreams.start).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + agent_id: "codex", + agent_name: "Codex", + }), + userId: "@codex:example", + })); + }); + + it("stores Beeper runtimes by OpenClaw host runtime", () => { + const hostRuntime = {}; + const scopedRuntime = new BeeperChannelRuntime({}); + + setBeeperChannelRuntimeForHost(hostRuntime, scopedRuntime); + + expect(getBeeperChannelRuntimeForHost(hostRuntime)).toBe(scopedRuntime); + expect(requireBeeperChannelRuntimeForHost(hostRuntime)).toBe(scopedRuntime); + + setBeeperChannelRuntimeForHost(hostRuntime, undefined); + expect(getBeeperChannelRuntimeForHost(hostRuntime)).toBeUndefined(); + expect(() => requireBeeperChannelRuntimeForHost(hostRuntime)).toThrow("Beeper channel runtime is not available"); + }); +}); diff --git a/packages/openclaw/src/beeper-channel-runtime.ts b/packages/openclaw/src/beeper-channel-runtime.ts new file mode 100644 index 0000000..e6e4233 --- /dev/null +++ b/packages/openclaw/src/beeper-channel-runtime.ts @@ -0,0 +1,512 @@ +import { readFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { + type PickleBridge, + type ChatInfo, + type Message, + type PortalKey, + type RemoteDeliveryReceipt, + type RemoteEdit, + type RemoteEventWithBundledParts, + type RemoteMarkUnread, + type RemoteMessageRemove, + type RemoteReadReceipt, + type RemoteReaction, + type RemoteReactionRemove, + type RemoteTyping, + type SentEvent, + type UserLogin, +} from "@beeper/pickle-bridge/types"; +import { createRemoteChatInfoChange, createRemoteMessage } from "@beeper/pickle-bridge/events"; +import { BeeperTurnStream } from "@beeper/pickle-bridge/beeper-stream"; +import { bridgeMediaMessageContent, type BridgeMediaKind } from "@beeper/pickle-bridge/media-message"; +import { AGUIEventType } from "./beeper-turn-events"; +import type { OpenClawAgentContact, OpenClawBeeperChannelInfo, OpenClawSessionBinding } from "./types"; + +export const BEEPER_CHANNEL_RUNTIME_CONTEXT_CAPABILITY = "beeper.runtime"; + +export interface BeeperChannelRuntimeOptions { + bridge?: PickleBridge; + getAgents?: () => readonly OpenClawAgentContact[]; + getBindingByRoom?: (roomId: string) => OpenClawSessionBinding | undefined; + getBindingBySessionKey?: (sessionKey: string) => OpenClawSessionBinding | undefined; + login?: UserLogin; + log?: (level: "debug" | "info" | "warn" | "error", message: string, data?: unknown) => void; + onActivity?: (patch: { lastEventAt?: number; lastOutboundAt?: number; lastTransportActivityAt?: number }) => void; + userId?: string; +} + +export interface BeeperOutboundMedia { + bytes?: Uint8Array; + caption?: string; + filename?: string; + kind?: BridgeMediaKind; + path?: string; + replyToId?: string | null; + threadRoot?: string; +} + +export class BeeperChannelRuntime { + readonly userId: string | undefined; + #bridge: PickleBridge | undefined; + #getAgents: () => readonly OpenClawAgentContact[]; + #getBindingByRoom: (roomId: string) => OpenClawSessionBinding | undefined; + #getBindingBySessionKey: (sessionKey: string) => OpenClawSessionBinding | undefined; + #login: UserLogin | undefined; + #log: BeeperChannelRuntimeOptions["log"]; + #onActivity: BeeperChannelRuntimeOptions["onActivity"]; + #activeStreams = new Map(); + + constructor(options: BeeperChannelRuntimeOptions) { + this.#bridge = options.bridge; + this.#getAgents = options.getAgents ?? (() => []); + this.#getBindingByRoom = options.getBindingByRoom ?? (() => undefined); + this.#getBindingBySessionKey = options.getBindingBySessionKey ?? (() => undefined); + this.#login = options.login; + this.#log = options.log; + this.#onActivity = options.onActivity; + this.userId = options.userId; + } + + listAgents(): readonly OpenClawAgentContact[] { + return this.#getAgents(); + } + + getRoomInfo(options: { roomId: string }): OpenClawBeeperChannelInfo { + const route = this.#bridgeRoute(options.roomId); + const binding = this.#resolveBinding(options.roomId); + const agent = binding?.agentId ? this.#getAgents().find((candidate) => candidate.agentId === binding.agentId) : undefined; + return { + ...(agent ? { agent } : {}), + ...(binding ? { binding } : {}), + portalKey: route.portalKey, + roomId: route.targetRoomId, + }; + } + + async sendText(options: { + content?: Record; + replyToId?: string | null; + roomId: string; + text: string; + threadRoot?: string | number | null; + }): Promise { + const content = { + body: options.text, + msgtype: "m.text", + ...options.content, + }; + return await this.#queueRemoteText(options.roomId, withMessageRelations(content, { + replyToId: options.replyToId, + threadRoot: options.threadRoot, + })); + } + + async sendMedia(options: BeeperOutboundMedia & { roomId: string }): Promise { + const bytes = options.bytes ?? (options.path ? await readFile(options.path) : undefined); + if (!bytes) { + throw new Error("Beeper media send requires bytes or a local file path."); + } + return await this.#queueRemoteMedia(options.roomId, { + bytes, + kind: options.kind ?? "file", + ...(options.caption !== undefined ? { caption: options.caption } : {}), + ...(options.filename !== undefined ? { filename: options.filename } : {}), + ...(options.replyToId !== undefined ? { replyToId: options.replyToId } : {}), + ...(options.threadRoot !== undefined ? { threadRoot: options.threadRoot } : {}), + }); + } + + async edit(options: { + content?: Record; + eventId: string; + roomId: string; + text: string; + }): Promise { + return await this.#queueRemoteEdit(options.roomId, options.eventId, { + body: options.text, + msgtype: "m.text", + ...options.content, + }); + } + + async redact(options: { eventId: string; reason?: string; roomId: string }): Promise { + await this.#queueRemoteMessageRemove(options.roomId, options.eventId); + } + + async react(options: { emoji: string; eventId: string; roomId: string }): Promise { + return await this.#queueRemoteReaction(options.roomId, options.eventId, options.emoji, false); + } + + async removeReaction(options: { emoji: string; eventId: string; roomId: string }): Promise { + await this.#queueRemoteReaction(options.roomId, options.eventId, options.emoji, true); + } + + async typing(options: { roomId: string; timeoutMs?: number; typing?: boolean }): Promise { + await this.#queueRemoteTyping(options.roomId, options.typing ?? true, options.timeoutMs); + } + + async readReceipt(options: { eventId: string; roomId: string }): Promise { + await this.#queueRemoteReceipt(options.roomId, options.eventId, "read_receipt"); + } + + async deliveryReceipt(options: { eventId: string; roomId: string }): Promise { + await this.#queueRemoteReceipt(options.roomId, options.eventId, "delivery_receipt"); + } + + async markUnread(options: { eventId: string; roomId: string; unread: boolean }): Promise { + await this.#queueRemoteMarkUnread(options.roomId, options.eventId, options.unread); + } + + async setRoomName(options: { name: string; roomId: string }): Promise { + await this.#queueRemoteChatInfo(options.roomId, { name: options.name }); + } + + async setRoomTopic(options: { roomId: string; topic: string }): Promise { + await this.#queueRemoteChatInfo(options.roomId, { topic: options.topic }); + } + + async setRoomAvatar(options: { avatarMxc: string; roomId: string }): Promise { + await this.#queueRemoteChatInfo(options.roomId, { avatar: { mxc: options.avatarMxc } }); + } + + createStreamPublisher(options: { + agentId?: string; + roomId: string; + runId: string; + sessionKey: string; + threadRoot?: string; + }): BeeperTurnStream { + const route = this.#bridgeRoute(options.roomId); + const binding = this.#resolveBinding(options.roomId) ?? this.#getBindingBySessionKey(options.sessionKey); + const agent = options.agentId ? this.#getAgents().find((candidate) => candidate.agentId === options.agentId) : undefined; + const userId = binding?.ghostUserId ?? agent?.ghostUserId ?? this.userId; + const publisher = route.bridge.createBeeperTurnStream({ + initialMessageMetadata: { + agent_id: options.agentId, + ...(agent?.displayName ? { agent_name: agent.displayName } : {}), + session_key: options.sessionKey, + }, + model: "openclaw/plugin", + roomId: options.roomId, + turnId: options.runId, + ...(options.agentId ? { agentId: options.agentId } : {}), + ...(agent?.displayName ? { agentName: agent.displayName } : {}), + ...(options.threadRoot ? { threadRoot: options.threadRoot } : {}), + ...(userId ? { userId } : {}), + }); + this.#activeStreams.set(options.sessionKey, publisher); + return publisher; + } + + clearActiveStream(sessionKey: string, publisher: BeeperTurnStream): void { + if (this.#activeStreams.get(sessionKey) === publisher) this.#activeStreams.delete(sessionKey); + } + + async publishActiveText(options: { + sessionKey?: string | null; + text: string; + }): Promise { + const sessionKey = options.sessionKey?.trim(); + if (!sessionKey) throw new Error("Beeper native stream send requires an active session key."); + const publisher = this.#activeStreams.get(sessionKey); + if (!publisher) throw new Error(`No active Beeper native stream for session ${sessionKey}.`); + await publisher.publish({ + delta: options.text, + messageId: publisher.turnId, + type: AGUIEventType.TEXT_MESSAGE_CONTENT, + }); + this.recordOutboundActivity(); + return { + eventId: publisher.targetEventId ?? publisher.turnId, + raw: { nativeStream: true, turnId: publisher.turnId }, + roomId: publisher.roomId, + }; + } + + debug(message: string, data?: unknown): void { + this.#log?.("debug", message, data); + } + + recordOutboundActivity(now = Date.now()): void { + this.#onActivity?.({ + lastEventAt: now, + lastOutboundAt: now, + lastTransportActivityAt: now, + }); + } + + async #queueRemoteText(roomId: string, content: Record): Promise { + const route = this.#bridgeRoute(roomId); + const messageId = openClawRemoteId(); + route.bridge.queueRemoteEvent(route.login, createRemoteMessage({ + convert: () => ({ + parts: [{ + content, + type: "m.room.message", + }], + }), + data: {}, + id: messageId, + portalKey: route.portalKey, + sender: this.#eventSender(roomId), + })); + await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); + return { eventId: messageId, raw: { bridgeQueued: true }, roomId }; + } + + async #queueRemoteMedia(roomId: string, options: { + bytes: Uint8Array; + caption?: string; + filename?: string; + kind: NonNullable; + replyToId?: string | null; + threadRoot?: string; + }): Promise { + const route = this.#bridgeRoute(roomId); + const upload = await route.bridge.uploadMedia({ + bytes: options.bytes, + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }); + const content = withMessageRelations(bridgeMediaMessageContent({ + contentUri: upload.contentUri, + kind: options.kind, + ...(options.caption !== undefined ? { caption: options.caption } : {}), + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }), { + replyToId: options.replyToId, + threadRoot: options.threadRoot, + }); + const messageId = openClawRemoteId(); + route.bridge.queueRemoteEvent(route.login, createRemoteMessage({ + convert: () => ({ + parts: [{ content, type: "m.room.message" }], + }), + data: {}, + id: messageId, + portalKey: route.portalKey, + sender: this.#eventSender(roomId), + })); + await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); + return { eventId: messageId, raw: { bridgeQueued: true }, roomId }; + } + + async #queueRemoteChatInfo(roomId: string, chatInfo: ChatInfo): Promise { + const route = this.#bridgeRoute(roomId); + route.bridge.queueRemoteEvent(route.login, createRemoteChatInfoChange({ + chatInfoChange: { chatInfo }, + portalKey: route.portalKey, + sender: this.#eventSender(roomId), + })); + await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); + } + + async #queueRemoteEdit(roomId: string, targetMessageId: string, content: Record): Promise { + const target = openClawTarget(targetMessageId); + const route = this.#bridgeRoute(roomId); + const messageId = openClawRemoteId(); + const event: RemoteEdit & Partial = { + convertEdit: async (_ctx, _portal, _intent, existing) => ({ + modifiedParts: [{ + content, + ...(existing[0] ? { part: existing[0] } : {}), + type: "m.room.message", + }], + }), + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + ...bundledTargetMethods(target), + getTargetMessage: () => target.messageId, + getType: () => "edit", + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); + return { eventId: messageId, raw: { bridgeQueued: true, targetMessageId: target.messageId }, roomId }; + } + + async #queueRemoteMessageRemove(roomId: string, targetMessageId: string): Promise { + const target = openClawTarget(targetMessageId); + const route = this.#bridgeRoute(roomId); + const event: RemoteMessageRemove & Partial = { + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + ...bundledTargetMethods(target), + getTargetMessage: () => target.messageId, + getType: () => "message_remove", + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); + } + + async #queueRemoteReaction(roomId: string, targetMessageId: string, emoji: string, remove: boolean): Promise { + const target = openClawTarget(targetMessageId); + const route = this.#bridgeRoute(roomId); + const reactionId = openClawRemoteId("reaction"); + const event: (RemoteReaction | RemoteReactionRemove) & Partial = { + getEmoji: () => emoji, + getID: () => reactionId, + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + ...bundledTargetMethods(target), + getTargetMessage: () => target.messageId, + getType: () => remove ? "reaction_remove" : "reaction", + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); + return { eventId: reactionId, raw: { bridgeQueued: true, targetMessageId: target.messageId }, roomId }; + } + + async #queueRemoteTyping(roomId: string, typing: boolean, timeoutMs: number | undefined): Promise { + const route = this.#bridgeRoute(roomId); + const event: RemoteTyping = { + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + ...(timeoutMs !== undefined ? { getTimeoutMs: () => timeoutMs } : {}), + getType: () => "typing", + isTyping: () => typing, + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); + } + + async #queueRemoteReceipt(roomId: string, targetMessageId: string, type: "read_receipt" | "delivery_receipt"): Promise { + const target = openClawTarget(targetMessageId); + const route = this.#bridgeRoute(roomId); + const event: (RemoteReadReceipt | RemoteDeliveryReceipt) & Partial = { + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + ...bundledTargetMethods(target), + getTargetMessage: () => target.messageId, + getType: () => type, + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); + } + + async #queueRemoteMarkUnread(roomId: string, targetMessageId: string, unread: boolean): Promise { + const target = openClawTarget(targetMessageId); + const route = this.#bridgeRoute(roomId); + const event: RemoteMarkUnread & Partial = { + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + ...bundledTargetMethods(target), + getTargetMessage: () => target.messageId, + getType: () => "mark_unread", + getUnread: () => unread, + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); + } + + #bridgeRoute(roomId: string): { bridge: PickleBridge; login: UserLogin; portalKey: PortalKey; targetRoomId: string } { + if (!this.#bridge || !this.#login) throw new Error("Beeper channel runtime requires a Pickle bridge and user login for outbound actions."); + const binding = this.#resolveBinding(roomId); + const targetRoomId = binding?.roomId ?? roomId; + const portal = this.#bridge.getPortalByMXID(targetRoomId); + if (!portal?.portalKey) throw new Error(`Beeper outbound target ${roomId} is not a bound bridge portal.`); + return { bridge: this.#bridge, login: this.#login, portalKey: portal.portalKey, targetRoomId }; + } + + #eventSender(roomId: string): { isFromMe: boolean; sender: string } { + const binding = this.#resolveBinding(roomId); + return { + isFromMe: true, + sender: binding?.ghostUserId ?? this.userId ?? "openclaw", + }; + } + + #resolveBinding(target: string): OpenClawSessionBinding | undefined { + const direct = this.#getBindingByRoom(target); + if (direct) return direct; + for (const sessionKey of beeperSessionKeyCandidates(target)) { + const binding = this.#getBindingBySessionKey(sessionKey); + if (binding) return binding; + } + return undefined; + } +} + +const runtimeByHost = new WeakMap(); + +export function setBeeperChannelRuntimeForHost(hostRuntime: object, runtime: BeeperChannelRuntime | undefined): void { + if (runtime) runtimeByHost.set(hostRuntime, runtime); + else runtimeByHost.delete(hostRuntime); +} + +export function getBeeperChannelRuntimeForHost(hostRuntime: object | undefined): BeeperChannelRuntime | undefined { + return hostRuntime ? runtimeByHost.get(hostRuntime) : undefined; +} + +export function requireBeeperChannelRuntimeForHost(hostRuntime: object | undefined): BeeperChannelRuntime { + const runtime = getBeeperChannelRuntimeForHost(hostRuntime); + if (!runtime) { + throw new Error("Beeper channel runtime is not available; start the Beeper bridge account first."); + } + return runtime; +} + +function withMessageRelations( + content: Record, + options: { replyToId?: string | number | null | undefined; threadRoot?: string | number | null | undefined }, +): Record { + if (!options.replyToId && options.threadRoot == null) return content; + const relatesTo = recordValue(content["m.relates_to"]) ?? {}; + return { + ...content, + "m.relates_to": { + ...relatesTo, + ...(options.replyToId ? { "m.in_reply_to": { event_id: String(options.replyToId) } } : {}), + ...(options.threadRoot != null ? { "m.thread": { event_id: String(options.threadRoot) } } : {}), + }, + }; +} + +function recordValue(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; +} + +function openClawRemoteId(prefix = "message"): string { + return `openclaw:${prefix}:${randomUUID()}`; +} + +function openClawTarget(eventId: string): { dbMessages?: Message[]; messageId: string } { + if (!eventId.startsWith("openclaw:")) { + if (eventId.startsWith("$")) { + return { + dbMessages: [{ + id: eventId, + mxid: eventId, + partId: "0", + }], + messageId: eventId, + }; + } + throw new Error(`Beeper bridge actions can only target OpenClaw bridge or Matrix event ids, got ${eventId}.`); + } + return { messageId: eventId }; +} + +function bundledTargetMethods(target: { dbMessages?: Message[] }): Partial> { + const dbMessages = target.dbMessages; + return dbMessages ? { getTargetDBMessage: () => dbMessages } : {}; +} + +function beeperSessionKeyCandidates(target: string): string[] { + const trimmed = target.trim(); + if (!trimmed) return []; + const candidates = new Set([trimmed]); + const parts = trimmed.split(":"); + if (parts[0] !== "agent" && parts.length >= 3) { + candidates.add(["agent", ...parts].join(":")); + } + return [...candidates]; +} diff --git a/packages/openclaw/src/beeper-setup.test.ts b/packages/openclaw/src/beeper-setup.test.ts new file mode 100644 index 0000000..ee5e0f0 --- /dev/null +++ b/packages/openclaw/src/beeper-setup.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vitest"; +import { + createOpenClawBeeperAppService, + loginToBeeperForOpenClaw, + setupOpenClawBeeperBridge, +} from "./beeper-setup"; + +describe("OpenClaw Beeper setup", () => { + it("derives a valid self-hosted bridge id from long OpenClaw device ids", async () => { + const { openClawBeeperBridgeId } = await import("./beeper-setup"); + const bridgeId = openClawBeeperBridgeId("322ff27928aa3d3592836316f21c16fb9e801719d0adb25c3ef3aa40858a8982"); + + expect(bridgeId).toBe("sh-openclaw-322ff27928aa3d359283"); + expect(bridgeId).toHaveLength(32); + expect(bridgeId).toMatch(/^[a-z0-9-]+$/); + }); + + it("logs in with OpenClaw device metadata and returns config credentials", async () => { + const seen: unknown[] = []; + const result = await loginToBeeperForOpenClaw({ + email: "batuhan@example.com", + getLoginCode: () => "123456", + openClawDeviceId: "OPENCLAW-DEVICE", + login: async (options) => { + seen.push(options); + return { + accessToken: "mx-token", + deviceId: "DEV", + homeserver: "https://matrix.beeper.com", + userId: "@batuhan:beeper.com", + }; + }, + }); + + expect(seen).toEqual([ + expect.objectContaining({ + email: "batuhan@example.com", + initialDeviceDisplayName: "Pickle OpenClaw", + metadata: { + bridge: "sh-openclaw-openclaw-device", + bridgeType: "openclaw", + openClawDeviceId: "OPENCLAW-DEVICE", + }, + }), + ]); + expect(result.config).toEqual({ + homeserver: "https://matrix.beeper.com", + matrixDeviceId: "DEV", + matrixUserId: "@batuhan:beeper.com", + }); + }); + + it("logs in with username/password when email login is not used", async () => { + const seen: unknown[] = []; + const result = await loginToBeeperForOpenClaw({ + env: "staging", + openClawDeviceId: "OPENCLAW-DEVICE", + username: "batuhan", + password: "secret", + passwordLogin: async (options) => { + seen.push(options); + return { + accessToken: "mx-token", + deviceId: "DEV", + homeserver: options.homeserver, + userId: "@batuhan:beeper-staging.com", + }; + }, + }); + + expect(seen).toEqual([ + expect.objectContaining({ + homeserver: "https://matrix.beeper-staging.com", + password: "secret", + username: "batuhan", + metadata: { + bridge: "sh-openclaw-openclaw-device", + bridgeType: "openclaw", + openClawDeviceId: "OPENCLAW-DEVICE", + }, + }), + ]); + expect(result.config).toEqual({ + homeserver: "https://matrix.beeper-staging.com", + matrixDeviceId: "DEV", + matrixUserId: "@batuhan:beeper-staging.com", + }); + }); + + it("registers the OpenClaw Beeper appservice with self-hosted defaults", async () => { + const seen: unknown[] = []; + const result = await createOpenClawBeeperAppService({ + accessToken: "mx-token", + matrixDeviceId: "DEV", + createAppServiceInit: async (options) => { + seen.push(options); + return { + homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", + homeserverDomain: "beeper.local", + registration: { + asToken: "as", + hsToken: "hs", + id: "appservice-uuid", + namespaces: { aliases: [], rooms: [], users: [] }, + senderLocalpart: "sh-openclawbot", + url: "http://127.0.0.1:29391", + }, + }; + }, + }); + + expect(seen).toEqual([ + expect.objectContaining({ + bridge: "sh-openclaw-dev", + bridgeType: "openclaw", + selfHosted: true, + token: "mx-token", + }), + ]); + expect(result.config).toEqual({ + appserviceId: "appservice-uuid", + asToken: "as", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", + homeserverDomain: "beeper.local", + hsToken: "hs", + }); + }); + + it("combines Beeper login and appservice registration config", async () => { + const result = await setupOpenClawBeeperBridge({ + email: "batuhan@example.com", + env: "staging", + getLoginCode: () => "123456", + openClawDeviceId: "OPENCLAW-DEVICE", + login: async () => ({ + accessToken: "mx-token", + deviceId: "DEV", + homeserver: "https://matrix.beeper-staging.com", + userId: "@batuhan:beeper-staging.com", + }), + createAppServiceInit: async (options) => { + expect(options).toMatchObject({ + baseDomain: "beeper-staging.com", + bridge: "sh-openclaw-openclaw-device", + token: "mx-token", + }); + expect(options).not.toHaveProperty("address"); + expect(options.homeserver).toBeUndefined(); + return { + homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", + registration: { + asToken: "as", + hsToken: "hs", + id: "appservice-uuid", + namespaces: { aliases: [], rooms: [], users: [] }, + senderLocalpart: "sh-openclawbot", + url: "http://127.0.0.1:29391", + }, + }; + }, + }); + + expect(result.config).toEqual({ + appserviceId: "appservice-uuid", + asToken: "as", + bridgeId: "sh-openclaw-openclaw-device", + homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@batuhan:beeper-staging.com", + }); + }); + +}); diff --git a/packages/openclaw/src/beeper-setup.ts b/packages/openclaw/src/beeper-setup.ts new file mode 100644 index 0000000..e2fcc5d --- /dev/null +++ b/packages/openclaw/src/beeper-setup.ts @@ -0,0 +1,197 @@ +import { + createBeeperAppServiceInit, + createBeeperLogin, + loginWithMatrixPassword, + type BeeperAuthOptions, + type BeeperEnvironment, + type CreateAppServiceOptions, + type MatrixAppserviceInitOptions, + type MatrixPasswordAuthOptions, +} from "@beeper/pickle-bridge/beeper"; +import { DEFAULT_BEEPER_BRIDGE_TYPE, openClawBeeperBridgeId } from "./ids"; +import { resolveOpenClawDeviceId } from "./openclaw-identity"; +import type { BeeperServerEnv, OpenClawBridgeConfig } from "./types"; + +export { DEFAULT_BEEPER_BRIDGE_TYPE, openClawBeeperBridgeId }; +export type { BeeperEnvironment, BeeperServerEnv }; + +export interface BeeperSetupAccount { + accessToken: string; + deviceId: string; + homeserver: string; + userId: string; +} + +export interface BeeperLoginForOpenClawOptions { + email?: string; + env?: BeeperServerEnv; + fetch?: typeof fetch; + getLoginCode?: () => Promise | string; + initialDeviceDisplayName?: string; + login?: (options: BeeperAuthOptions) => Promise; + metadata?: Record; + openClawDeviceId?: string; + password?: string; + passwordLogin?: (options: MatrixPasswordAuthOptions) => Promise; + username?: string; +} + +export interface BeeperLoginForOpenClawResult { + account: BeeperSetupAccount; + config: Pick; +} + +export interface CreateOpenClawBeeperAppServiceOptions { + accessToken: string; + baseDomain?: string; + bridge?: string; + bridgeType?: string; + createAppServiceInit?: (options: CreateOpenClawBeeperAppServiceRequest) => Promise; + fetch?: typeof fetch; + matrixDeviceId?: string; + username?: string; +} + +export type CreateOpenClawBeeperAppServiceRequest = CreateAppServiceOptions & { + baseDomain?: string; + fetch?: typeof fetch; + token: string; + username?: string; +}; + +export interface CreateOpenClawBeeperAppServiceResult { + config: Pick; + init: MatrixAppserviceInitOptions; +} + +export interface SetupOpenClawBeeperBridgeOptions extends BeeperLoginForOpenClawOptions { + createAppServiceInit?: CreateOpenClawBeeperAppServiceOptions["createAppServiceInit"]; + openClawDeviceId?: string; +} + +export interface SetupOpenClawBeeperBridgeResult { + account: BeeperSetupAccount; + config: Pick; + init: MatrixAppserviceInitOptions; +} + +export async function loginToBeeperForOpenClaw(options: BeeperLoginForOpenClawOptions): Promise { + const env = beeperAuthEnv(options.env); + const openClawDeviceId = options.openClawDeviceId ?? await resolveOpenClawDeviceId(); + const bridgeId = openClawBeeperBridgeId(openClawDeviceId); + const metadata = { ...options.metadata, bridge: bridgeId, bridgeType: DEFAULT_BEEPER_BRIDGE_TYPE, openClawDeviceId }; + if (options.username || options.password) { + if (!options.username || !options.password) throw new Error("Beeper username/password login requires both username and password"); + const login = options.passwordLogin ?? loginWithMatrixPassword; + const request: MatrixPasswordAuthOptions = { + homeserver: beeperMatrixHomeserver(env), + initialDeviceDisplayName: options.initialDeviceDisplayName ?? "Pickle OpenClaw", + metadata, + password: options.password, + username: options.username, + }; + if (options.fetch !== undefined) request.fetch = options.fetch; + const account = await login(request); + return { + account, + config: { + homeserver: account.homeserver, + matrixDeviceId: account.deviceId, + matrixUserId: account.userId, + }, + }; + } + if (!options.email) throw new Error("Beeper setup requires email login or username/password login"); + const login = options.login ?? createBeeperLogin; + const request: BeeperAuthOptions = { + email: options.email, + initialDeviceDisplayName: options.initialDeviceDisplayName ?? "Pickle OpenClaw", + metadata, + env, + }; + if (options.fetch !== undefined) request.fetch = options.fetch; + if (options.getLoginCode !== undefined) request.getLoginCode = options.getLoginCode; + const account = await login(request); + return { + account, + config: { + homeserver: account.homeserver, + matrixDeviceId: account.deviceId, + matrixUserId: account.userId, + }, + }; +} + +export async function createOpenClawBeeperAppService( + options: CreateOpenClawBeeperAppServiceOptions +): Promise { + const createInit = options.createAppServiceInit ?? createBeeperAppServiceInit; + const bridge = options.bridge ?? (options.matrixDeviceId ? openClawBeeperBridgeId(options.matrixDeviceId) : undefined); + if (!bridge) throw new Error("OpenClaw Beeper appservice registration requires a bridge id or device id"); + const request: CreateOpenClawBeeperAppServiceRequest = { + bridge, + bridgeType: options.bridgeType ?? DEFAULT_BEEPER_BRIDGE_TYPE, + selfHosted: true, + token: options.accessToken, + }; + if (options.baseDomain !== undefined) request.baseDomain = options.baseDomain; + if (options.fetch !== undefined) request.fetch = options.fetch; + request.postState = true; + if (options.username !== undefined) request.username = options.username; + const init = await createInit(request); + const config: CreateOpenClawBeeperAppServiceResult["config"] = { + appserviceId: init.registration.id, + asToken: init.registration.asToken, + bridgeId: bridge, + homeserver: init.homeserver, + hsToken: init.registration.hsToken, + }; + if (init.homeserverDomain !== undefined) config.homeserverDomain = init.homeserverDomain; + return { + config, + init, + }; +} + +export async function setupOpenClawBeeperBridge( + options: SetupOpenClawBeeperBridgeOptions +): Promise { + const env = options.env ?? "prod"; + const authEnv = beeperAuthEnv(env); + const openClawDeviceId = options.openClawDeviceId ?? await resolveOpenClawDeviceId(); + const login = await loginToBeeperForOpenClaw({ ...options, env, openClawDeviceId }); + const bridgeId = openClawBeeperBridgeId(openClawDeviceId); + const appserviceOptions: CreateOpenClawBeeperAppServiceOptions = { + accessToken: login.account.accessToken, + bridge: bridgeId, + }; + const baseDomain = beeperBaseDomain(authEnv); + if (baseDomain !== undefined) appserviceOptions.baseDomain = baseDomain; + if (options.createAppServiceInit !== undefined) appserviceOptions.createAppServiceInit = options.createAppServiceInit; + if (options.fetch !== undefined) appserviceOptions.fetch = options.fetch; + const appservice = await createOpenClawBeeperAppService(appserviceOptions); + return { + account: login.account, + config: { + ...login.config, + ...appservice.config, + }, + init: appservice.init, + }; +} + +export function beeperBaseDomain(env: BeeperEnvironment | undefined): string | undefined { + if (env === undefined || env === "production") return undefined; + if (env === "dev") return "beeper-dev.com"; + if (env === "local") return "beeper.localtest.me"; + return "beeper-staging.com"; +} + +export function beeperMatrixHomeserver(env: BeeperEnvironment | undefined): string { + return `https://matrix.${beeperBaseDomain(env) ?? "beeper.com"}`; +} + +function beeperAuthEnv(env: BeeperServerEnv | undefined): BeeperEnvironment { + if (env === undefined || env === "prod") return "production"; + return env; +} diff --git a/packages/openclaw/src/beeper-turn-events.ts b/packages/openclaw/src/beeper-turn-events.ts new file mode 100644 index 0000000..415cf29 --- /dev/null +++ b/packages/openclaw/src/beeper-turn-events.ts @@ -0,0 +1,62 @@ +export { EventType as AGUIEventType } from "@beeper/pickle-ag-ui"; +export type { AGUIEvent } from "@beeper/pickle-ag-ui"; + +import { EventType as AGUIEventType, type AGUIEvent } from "@beeper/pickle-ag-ui"; +import { defaultBeeperApprovalActions, defaultBeeperApprovalChoices } from "./approval"; + +export interface ApprovalRunState { + toolCallIdToApprovalId: Record; +} + +export function createApprovalRunState(): ApprovalRunState { + return { toolCallIdToApprovalId: {} }; +} + +export function mapOpenClawCustom(name: string, value: unknown): AGUIEvent[] { + return [{ name, type: AGUIEventType.CUSTOM, value }]; +} + +export function mapOpenClawApprovalRequest( + state: ApprovalRunState, + event: { approvalId?: string; message?: string; toolCallId?: string; toolName?: string }, +): AGUIEvent { + const toolCallId = event.toolCallId ?? event.approvalId ?? "approval"; + const approvalId = event.approvalId ?? `approval_${toolCallId}`; + state.toolCallIdToApprovalId[toolCallId] = approvalId; + return { + name: "approval-requested", + type: AGUIEventType.CUSTOM, + value: { + approval: { + id: approvalId, + needsApproval: true, + }, + approvalActions: defaultBeeperApprovalActions(), + approvalMessageId: approvalId, + choices: defaultBeeperApprovalChoices(), + message: event.message, + toolCallId, + toolName: event.toolName, + }, + }; +} + +export function mapOpenClawApprovalResponse(event: { + approvalId: string; + approved: boolean; + approvedAlways?: boolean; + toolCallId?: string; +}): AGUIEvent { + return { + name: "approval-responded", + type: AGUIEventType.CUSTOM, + value: { + approval: { + always: event.approvedAlways, + approved: event.approved, + id: event.approvalId, + }, + toolCallId: event.toolCallId, + }, + }; +} diff --git a/packages/openclaw/src/bridge-agent.test.ts b/packages/openclaw/src/bridge-agent.test.ts new file mode 100644 index 0000000..7dc16e0 --- /dev/null +++ b/packages/openclaw/src/bridge-agent.test.ts @@ -0,0 +1,317 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createDefaultConfig } from "./config"; +import { OpenClawMatrixBridgeAgent } from "./bridge-agent"; +import { OpenClawPluginRuntimeAdapter, type OpenClawGatewayEvent, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; +import { OpenClawBridgeRegistry } from "./registry"; +import type { OpenClawSessionBinding } from "./types"; + +describe("OpenClawMatrixBridgeAgent", () => { + it("syncs OpenClaw agents into bridge contacts", async () => { + const registry = await tempRegistry(); + registry.upsertAgent({ agentId: "old", displayName: "Old", ghostUserId: "@sh-openclaw_agent_old:localhost" }); + const agent = new OpenClawMatrixBridgeAgent({ + registry, + runtime: runtimeWith({ + responses: { "agents.list": { agents: [{ avatarMxc: "mxc://example/codex", id: "codex", name: "Codex" }] } }, + }), + }); + + await agent.syncAgentContacts(); + expect(registry.getAgent("codex")).toMatchObject({ + agentId: "codex", + avatarMxc: "mxc://example/codex", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + }); + expect(registry.getAgent("old")).toBeUndefined(); + }); + + it("sends Matrix room text to the bound OpenClaw session", async () => { + const registry = await tempRegistry(); + registry.upsertBinding(testBinding()); + const runtime = runtimeWith({ + responses: {}, + }); + const sendTurn = vi.fn(async () => ({ runId: "run_1", sessionKey: "agent:codex:main" })); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, sendTurn }); + + await agent.handleMatrixText({ + eventId: "$event", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + }); + + expect(sendTurn).toHaveBeenCalledWith({ + idempotencyKey: "$event", + matrix: { roomId: "!room:example.com" }, + message: "hello", + sessionKey: "agent:codex:main", + }); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.patch", { + agentId: "codex", + key: "agent:codex:main", + reasoningLevel: "stream", + verboseLevel: "full", + }); + expect(registry.getBindingByRoom("!room:example.com")?.lastRunId).toBe("run_1"); + }); + + it("uses an injected Beeper turn sender for live Matrix room turns", async () => { + const registry = await tempRegistry(); + registry.upsertBinding(testBinding()); + const runtime = runtimeWith({}); + const sendTurn = vi.fn(async () => ({ runId: "run_direct", sessionKey: "agent:codex:main" })); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, sendTurn }); + + await agent.handleMatrixText({ + eventId: "$direct", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + }); + + expect(sendTurn).toHaveBeenCalledWith({ + idempotencyKey: "$direct", + matrix: { roomId: "!room:example.com" }, + message: "hello", + sessionKey: "agent:codex:main", + }); + expect(registry.getBindingByRoom("!room:example.com")?.lastRunId).toBe("run_direct"); + }); + + + it("does not poison message dedupe when OpenClaw send fails before persistence", async () => { + const registry = await tempRegistry(); + registry.upsertBinding(testBinding()); + const runtime = runtimeWith({ responses: {} }); + const sendTurn = vi.fn(async () => { + throw new Error("turn down"); + }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, sendTurn }); + + await expect(agent.handleMatrixText({ + eventId: "$retryable", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + })).rejects.toThrow("turn down"); + + expect(registry.hasDedupe("$retryable")).toBe(false); + + sendTurn.mockResolvedValueOnce({ runId: "run_retry", sessionKey: "agent:codex:main" }); + + await agent.handleMatrixText({ + eventId: "$retryable", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + }); + + expect(registry.hasDedupe("$retryable")).toBe(true); + expect(sendTurn).toHaveBeenLastCalledWith({ + idempotencyKey: "$retryable", + matrix: { roomId: "!room:example.com" }, + message: "hello", + sessionKey: "agent:codex:main", + }); + }); + + it("does not start duplicate turns for the same Matrix event while the first send is in flight", async () => { + const registry = await tempRegistry(); + registry.upsertBinding(testBinding()); + const runtime = runtimeWith({ responses: {} }); + let finishTurn!: () => void; + const sendTurn = vi.fn(async () => { + await new Promise((resolve) => { finishTurn = resolve; }); + return { runId: "run_1", sessionKey: "agent:codex:main" }; + }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, sendTurn }); + const turn = { + eventId: "$duplicate", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + }; + + const first = agent.handleMatrixText(turn); + await vi.waitFor(() => expect(sendTurn).toHaveBeenCalledOnce()); + await agent.handleMatrixText(turn); + finishTurn(); + await first; + + expect(sendTurn).toHaveBeenCalledOnce(); + expect(registry.hasDedupe("$duplicate")).toBe(true); + expect(registry.getBindingByRoom("!room:example.com")?.lastRunId).toBe("run_1"); + }); + + it("serializes different Matrix events in the same OpenClaw session", async () => { + const registry = await tempRegistry(); + registry.upsertBinding(testBinding()); + const runtime = runtimeWith({ responses: {} }); + let finishFirst!: () => void; + const sendTurn = vi.fn(async (options: { idempotencyKey?: string }) => { + if (options.idempotencyKey === "$first") { + await new Promise((resolve) => { finishFirst = resolve; }); + return { runId: "run_1", sessionKey: "agent:codex:main" }; + } + return { runId: "run_2", sessionKey: "agent:codex:main" }; + }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, sendTurn }); + + const first = agent.handleMatrixText({ + eventId: "$first", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "one", + }); + await vi.waitFor(() => expect(sendTurn).toHaveBeenCalledOnce()); + const second = agent.handleMatrixText({ + eventId: "$second", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "two", + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(sendTurn).toHaveBeenCalledOnce(); + + finishFirst(); + await Promise.all([first, second]); + + expect(sendTurn.mock.calls.map(([options]) => options.idempotencyKey)).toEqual(["$first", "$second"]); + expect(registry.getBindingByRoom("!room:example.com")?.lastRunId).toBe("run_2"); + }); + + it("creates an OpenClaw session before sending the first message in an agent contact DM", async () => { + const registry = await tempRegistry(); + const pendingBinding = testBinding(); + delete pendingBinding.sessionKey; + registry.upsertBinding({ + ...pendingBinding, + }); + const runtime = runtimeWith({ + events: [ + { event: "run.completed", payload: { runId: "run_1", type: "run.completed" } }, + ], + responses: { + "sessions.create": { key: "agent:codex:session_1", sessionId: "session_1" }, + }, + }); + const sendTurn = vi.fn(async () => ({ runId: "run_1", sessionKey: "agent:codex:session_1" })); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, sendTurn }); + + await agent.handleMatrixText({ + eventId: "$event", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + }); + + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", { + agentId: "codex", + }); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.patch", { + agentId: "codex", + key: "agent:codex:session_1", + reasoningLevel: "stream", + verboseLevel: "full", + }); + expect(sendTurn).toHaveBeenCalledWith({ + idempotencyKey: "$event", + matrix: { roomId: "!room:example.com" }, + message: "hello", + sessionKey: "agent:codex:session_1", + }); + expect(registry.getBindingByRoom("!room:example.com")?.sessionKey).toBe("agent:codex:session_1"); + }); + + it("forwards Beeper approval responses back to OpenClaw", async () => { + const registry = await tempRegistry(); + const runtime = runtimeWith({ + responses: { + "exec.approval.resolve": { ok: true }, + "plugin.approval.resolve": { ok: true }, + }, + }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime }); + + await expect(agent.handleApprovalContent({ + approvalId: "approval_1", + approved: true, + toolCallId: "call_1", + type: "tool-approval-response", + })).resolves.toEqual({ + approvalId: "approval_1", + approved: true, + approvedAlways: false, + decision: "allow_once", + toolCallId: "call_1", + }); + expect(runtime.transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1", + decision: "approve", + toolCallId: "call_1", + }); + + await expect(agent.handleApprovalContent({ + approvalId: "plugin:approval_2", + approved: false, + type: "tool-approval-response", + })).resolves.toEqual({ + approvalId: "plugin:approval_2", + approvalKind: "plugin", + approved: false, + approvedAlways: false, + decision: "deny", + }); + expect(runtime.transport.request).toHaveBeenCalledWith("plugin.approval.resolve", { + approvalId: "plugin:approval_2", + decision: "deny", + }); + }); +}); + +async function tempRegistry(): Promise { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-agent-")); + const registry = new OpenClawBridgeRegistry(resolve(dir, "registry.json")); + await registry.load(); + return registry; +} + +function testBinding(): OpenClawSessionBinding { + return { + agentId: "codex", + createdAt: 1, + ghostUserId: "@sh-openclaw_agent_codex:example.com", + id: "binding", + roomId: "!room:example.com", + sessionKey: "agent:codex:main", + updatedAt: 1, + }; +} + +function runtimeWith(options: { + events?: OpenClawGatewayEvent[]; + responses?: Record; +}): OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } } { + const responses = options.responses ?? {}; + const transport = { + async *events(filter?: (event: OpenClawGatewayEvent) => boolean) { + for (const event of options.events ?? []) { + if (!filter || filter(event)) yield event; + } + }, + request: vi.fn(async (method: string) => { + const response = responses[method]; + if (response instanceof Error) throw response; + return response; + }), + }; + return new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }) as OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } }; +} diff --git a/packages/openclaw/src/bridge-agent.ts b/packages/openclaw/src/bridge-agent.ts new file mode 100644 index 0000000..a34317c --- /dev/null +++ b/packages/openclaw/src/bridge-agent.ts @@ -0,0 +1,137 @@ +import { + approvalKindForId, + parseApprovalResponseContent, + toOpenClawApprovalResolvePayload, + type ParsedApprovalResponse, +} from "./approval"; +import { + type OpenClawMatrixMessageMetadata, + type OpenClawRunRef, + type OpenClawSessionCreateOptions, + type OpenClawSessionPatchOptions, + type OpenClawSessionSendOptions, + type OpenClawSessionTurnRuntime, +} from "./openclaw-runtime"; +import type { OpenClawBridgeRegistry } from "./registry"; +import type { OpenClawSessionBinding } from "./types"; + +export interface MatrixTextTurn { + attachments?: unknown[]; + eventId: string; + matrix?: OpenClawMatrixMessageMetadata; + roomId: string; + replyToEventId?: string; + sender: string; + text: string; +} + +export class OpenClawMatrixBridgeAgent { + readonly registry: OpenClawBridgeRegistry; + readonly runtime: OpenClawSessionTurnRuntime; + readonly #sendTurn: (options: OpenClawSessionSendOptions) => Promise; + readonly #configuredSessions = new Set(); + readonly #inFlightEvents = new Set(); + readonly #turnsByBinding = new Map>(); + + constructor(options: { + registry: OpenClawBridgeRegistry; + runtime: OpenClawSessionTurnRuntime; + sendTurn?: (options: OpenClawSessionSendOptions) => Promise; + }) { + this.registry = options.registry; + this.runtime = options.runtime; + this.#sendTurn = options.sendTurn ?? ((sendOptions) => this.runtime.sendMessage(sendOptions)); + } + + async syncAgentContacts(): Promise { + this.registry.replaceAgents(await this.runtime.listAgentContacts()); + await this.registry.save(); + } + + async handleMatrixText(turn: MatrixTextTurn): Promise { + if (this.registry.hasDedupe(turn.eventId)) return; + if (this.#inFlightEvents.has(turn.eventId)) return; + const binding = this.registry.getBindingByRoom(turn.roomId); + if (!binding) { + this.registry.markDedupe(turn.eventId); + await this.registry.save(); + return; + } + const processTurn = async () => { + const sessionKey = await this.ensureSession(binding); + const matrix: OpenClawMatrixMessageMetadata = { + ...(turn.matrix ?? {}), + roomId: turn.roomId, + }; + const run = await this.#sendTurn({ + ...(turn.attachments && turn.attachments.length > 0 ? { attachments: turn.attachments } : {}), + idempotencyKey: turn.eventId, + matrix, + message: turn.text, + ...(turn.replyToEventId ? { replyTo: { eventId: turn.replyToEventId, roomId: turn.roomId } } : {}), + sessionKey, + }); + this.registry.updateBinding(binding.id, (current) => ({ + ...current, + lastMatrixEventId: turn.eventId, + lastRunId: run.runId, + sessionKey: run.sessionKey, + updatedAt: Date.now(), + })); + this.registry.markDedupe(turn.eventId); + await this.registry.save(); + }; + this.#inFlightEvents.add(turn.eventId); + const previous = this.#turnsByBinding.get(binding.id) ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(processTurn); + this.#turnsByBinding.set(binding.id, current); + try { + await current; + } finally { + if (this.#turnsByBinding.get(binding.id) === current) this.#turnsByBinding.delete(binding.id); + this.#inFlightEvents.delete(turn.eventId); + } + } + + async handleApprovalContent(content: unknown, approvalId?: string): Promise { + const response = parseApprovalResponseContent(content); + const resolvedApprovalId = response?.approvalId ?? approvalId; + if (!response || !resolvedApprovalId) return undefined; + const inferredApprovalKind = approvalKindForId(resolvedApprovalId); + if (!response.approvalKind && inferredApprovalKind) response.approvalKind = inferredApprovalKind; + await this.runtime.resolveApproval(toOpenClawApprovalResolvePayload(resolvedApprovalId, response)); + return response; + } + + async ensureSession(binding: OpenClawSessionBinding): Promise { + if (binding.sessionKey) { + await this.ensureSessionConfiguration({ + agentId: binding.agentId, + key: binding.sessionKey, + reasoningLevel: "stream", + verboseLevel: "full", + }); + return binding.sessionKey; + } + const createOptions: OpenClawSessionCreateOptions = { + agentId: binding.agentId, + reasoningLevel: "stream", + verboseLevel: "full", + }; + if (binding.label !== undefined) createOptions.label = binding.label; + const session = await this.runtime.createSession(createOptions); + this.registry.updateBinding(binding.id, (current) => ({ + ...current, + sessionKey: session.key, + updatedAt: Date.now(), + })); + this.#configuredSessions.add(session.key); + return session.key; + } + + private async ensureSessionConfiguration(options: OpenClawSessionPatchOptions): Promise { + if (this.#configuredSessions.has(options.key)) return; + await this.runtime.patchSession(options); + this.#configuredSessions.add(options.key); + } +} diff --git a/packages/openclaw/src/cli.test.ts b/packages/openclaw/src/cli.test.ts new file mode 100644 index 0000000..d45ac3c --- /dev/null +++ b/packages/openclaw/src/cli.test.ts @@ -0,0 +1,281 @@ +import { mkdtemp, readFile, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { runCli } from "./cli"; + +describe("pickle-openclaw CLI", () => { + it("only exposes Beeper login and whoami commands", async () => { + const helpIO = captureIO(); + await expect(runCli(["--help"], helpIO)).resolves.toBe(0); + expect(helpIO.stdoutText).toContain("login"); + expect(helpIO.stdoutText).toContain("whoami"); + expect(helpIO.stdoutText).toContain("--server-env "); + expect(helpIO.stdoutText).not.toContain("beeper-login"); + expect(helpIO.stdoutText).not.toContain("beeper-register"); + expect(helpIO.stdoutText).not.toContain("rpc"); + expect(helpIO.stdoutText).not.toContain("smoke"); + + const unknownIO = captureIO(); + await expect(runCli(["rpc"], unknownIO)).resolves.toBe(2); + expect(unknownIO.stderrText).toContain("Unknown command: rpc"); + expect(unknownIO.stderrText).not.toContain("OPENCLAW_GATEWAY_TOKEN"); + }); + + it("logs in to Beeper, registers the appservice, and writes a secure config", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-login-")); + const configPath = join(dir, "config.json"); + const setupBridge = vi.fn(async () => ({ + account: { + accessToken: "mx-token", + deviceId: "DEVICE", + homeserver: "https://matrix.beeper.com", + userId: "@batuhan:beeper.com", + }, + config: { + appserviceId: "sh-openclaw-device", + asToken: "as-token", + bridgeId: "sh-openclaw-device", + homeserver: "https://matrix.beeper.com", + hsToken: "hs-token", + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper.com", + }, + init: { + homeserver: "https://matrix.beeper.com", + registration: { + asToken: "as-token", + hsToken: "hs-token", + id: "sh-openclaw-device", + senderLocalpart: "sh-openclaw-devicebot", + url: "websocket", + }, + }, + })); + const io = captureIO("123456\n"); + + await expect(runCli([ + "login", + "--config", + configPath, + "--data-dir", + dir, + "--email", + "you@example.com", + "--server-env", + "staging", + ], io, { setupBridge })).resolves.toBe(0); + + expect(setupBridge).toHaveBeenCalledWith(expect.objectContaining({ + email: "you@example.com", + env: "staging", + getLoginCode: expect.any(Function), + })); + await expect(setupBridge.mock.calls[0]?.[0].getLoginCode()).resolves.toBe("123456"); + expect((await stat(configPath)).mode & 0o777).toBe(0o600); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + appserviceId: "sh-openclaw-device", + asToken: "as-token", + homeserver: "https://matrix.beeper.com", + hsToken: "hs-token", + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper.com", + serverEnv: "staging", + }); + const output = JSON.parse(io.stdoutText); + expect(output.account).toMatchObject({ + appserviceId: "sh-openclaw-device", + bridgeId: "sh-openclaw-device", + canConnect: true, + deviceId: "DEVICE", + serverEnv: "staging", + userId: "@batuhan:beeper.com", + }); + expect(output).not.toHaveProperty("init"); + expect(io.stdoutText).not.toContain("mx-token"); + expect(io.stdoutText).not.toContain("as-token"); + expect(io.stdoutText).not.toContain("hs-token"); + expect(io.stdoutText).not.toContain("bridge-manager-token"); + }); + + it("prompts for the Beeper login code when one is not provided", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-login-prompt-")); + const setupBridge = vi.fn(async () => ({ + account: { + accessToken: "mx-token", + deviceId: "DEVICE", + homeserver: "https://matrix.beeper.com", + userId: "@alice:beeper.com", + }, + config: { + appserviceId: "sh-openclaw-device", + asToken: "as-token", + bridgeId: "sh-openclaw-device", + homeserver: "https://matrix.beeper.com", + hsToken: "hs-token", + matrixDeviceId: "DEVICE", + matrixUserId: "@alice:beeper.com", + }, + init: { + homeserver: "https://matrix.beeper.com", + registration: { + asToken: "as-token", + hsToken: "hs-token", + id: "sh-openclaw-device", + senderLocalpart: "sh-openclaw-devicebot", + url: "websocket", + }, + }, + })); + const io = captureIO("654321\n"); + + await expect(runCli([ + "login", + "--config", + join(dir, "config.json"), + "--email", + "alice@example.com", + ], io, { setupBridge })).resolves.toBe(0); + + await expect(setupBridge.mock.calls[0]?.[0].getLoginCode()).resolves.toBe("654321"); + expect(io.stderrText).toContain("Enter Beeper login code:"); + }); + + it("can log in with username/password without prompting for OTP", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-password-")); + const setupBridge = successfulSetupBridge(); + const io = captureIO(); + + await expect(runCli([ + "login", + "--config", + join(dir, "config.json"), + "--username", + "batuhan", + "--password", + "secret", + "--server-env", + "staging", + ], io, { setupBridge })).resolves.toBe(0); + + expect(setupBridge).toHaveBeenCalledWith(expect.objectContaining({ + env: "staging", + password: "secret", + username: "batuhan", + })); + expect(setupBridge.mock.calls[0]?.[0]).not.toHaveProperty("getLoginCode"); + expect(io.stderrText).not.toContain("Enter Beeper login code:"); + }); + + it("rejects ambiguous login credentials", async () => { + const io = captureIO(); + + await expect(runCli([ + "login", + "--email", + "you@example.com", + "--username", + "batuhan", + "--password", + "secret", + ], io)).resolves.toBe(1); + + expect(io.stderrText).toContain("Choose only one login method"); + }); + + it("prints the saved Beeper bridge identity", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-whoami-")); + const configPath = join(dir, "config.json"); + await runCli([ + "login", + "--config", + configPath, + "--email", + "you@example.com", + ], captureIO("123456\n"), { setupBridge: successfulSetupBridge() }); + const io = captureIO(); + + await expect(runCli(["whoami", "--config", configPath], io)).resolves.toBe(0); + + expect(JSON.parse(io.stdoutText)).toEqual({ + appserviceId: "sh-openclaw-device", + bridgeId: "sh-openclaw-device", + canConnect: true, + deviceId: "DEVICE", + homeserver: "https://matrix.beeper.com", + serverEnv: "prod", + userId: "@batuhan:beeper.com", + }); + }); + + it("reports incomplete identity when no Beeper login is saved", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-empty-")); + const io = captureIO(); + + await expect(runCli(["whoami", "--data-dir", dir], io)).resolves.toBe(0); + + expect(JSON.parse(io.stdoutText)).toMatchObject({ + canConnect: false, + deviceId: null, + homeserver: null, + userId: null, + }); + }); +}); + +function successfulSetupBridge() { + return vi.fn(async () => ({ + account: { + accessToken: "mx-token", + deviceId: "DEVICE", + homeserver: "https://matrix.beeper.com", + userId: "@batuhan:beeper.com", + }, + config: { + appserviceId: "sh-openclaw-device", + asToken: "as-token", + bridgeId: "sh-openclaw-device", + homeserver: "https://matrix.beeper.com", + hsToken: "hs-token", + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper.com", + }, + init: { + homeserver: "https://matrix.beeper.com", + registration: { + asToken: "as-token", + hsToken: "hs-token", + id: "sh-openclaw-device", + senderLocalpart: "sh-openclaw-devicebot", + url: "websocket", + }, + }, + })); +} + +function captureIO(stdin = "") { + const stdout: string[] = []; + const stderr: string[] = []; + return { + get stderrText() { + return stderr.join(""); + }, + get stdoutText() { + return stdout.join(""); + }, + stderr: { + write: (chunk: string | Uint8Array) => { + stderr.push(String(chunk)); + return true; + }, + }, + stdin: Readable.from([stdin]), + stdout: { + write: (chunk: string | Uint8Array) => { + stdout.push(String(chunk)); + return true; + }, + }, + }; +} diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts new file mode 100644 index 0000000..8fd4278 --- /dev/null +++ b/packages/openclaw/src/cli.ts @@ -0,0 +1,170 @@ +#!/usr/bin/env node +import { createInterface } from "node:readline/promises"; +import { setupOpenClawBeeperBridge, type BeeperServerEnv } from "./beeper-setup"; +import { createDefaultConfig, defaultConfigPath, readConfig, writeConfig } from "./config"; +import type { OpenClawBridgeConfig } from "./types"; + +export interface CliIO { + stderr: Pick; + stdin?: NodeJS.ReadableStream; + stdout: Pick; +} + +export interface CliDeps { + setupBridge?: typeof setupOpenClawBeeperBridge; +} + +export async function runCli(argv = process.argv.slice(2), io: CliIO = process, deps: CliDeps = {}): Promise { + const [command, ...args] = argv; + try { + if (!command || command === "help" || command === "--help" || command === "-h") { + io.stdout.write(helpText()); + return 0; + } + if (command === "login") { + const options = parseOptions(args); + const email = stringOption(options, "email"); + const username = stringOption(options, "username"); + const password = stringOption(options, "password"); + const authMethods = [email, username || password].filter(Boolean).length; + if (authMethods === 0) throw new Error("Missing required option --email or --username/--password"); + if (authMethods > 1) throw new Error("Choose only one login method"); + if ((username && !password) || (password && !username)) throw new Error("Username/password login requires both --username and --password"); + const setupOptions: Parameters[0] = {}; + if (email !== undefined) setupOptions.email = email; + if (username !== undefined) setupOptions.username = username; + if (password !== undefined) setupOptions.password = password; + const env = serverEnvOption(options); + if (env !== undefined) setupOptions.env = env; + if (email !== undefined) setupOptions.getLoginCode = () => promptForLoginCode(io); + const result = await (deps.setupBridge ?? setupOpenClawBeeperBridge)(setupOptions); + const config = createDefaultConfig({ + ...configOverridesFromOptions(options), + ...beeperRuntimeOverridesFromOptions(options), + ...result.config, + }); + await writeConfig(config, stringOption(options, "config") ?? defaultConfigPath(config.dataDir)); + io.stdout.write(`${JSON.stringify({ + account: whoamiPayload(config), + }, null, 2)}\n`); + return 0; + } + if (command === "whoami") { + const config = await loadConfig(parseOptions(args)); + io.stdout.write(`${JSON.stringify(whoamiPayload(config), null, 2)}\n`); + return 0; + } + io.stderr.write(`Unknown command: ${command}\n\n${helpText()}`); + return 2; + } catch (error) { + io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } +} + +function helpText(): string { + return [ + "pickle-openclaw ", + "", + "Commands:", + " login Log in to Beeper and register the OpenClaw appservice", + " whoami Print the saved Beeper bridge identity", + "", + "Common options:", + " --config ", + " --data-dir ", + " --email
", + " --username ", + " --password ", + " --server-env ", + "", + ].join("\n"); +} + +function configOverridesFromOptions(options: Map): Partial { + const overrides: Partial = {}; + const dataDir = stringOption(options, "data-dir"); + if (dataDir) overrides.dataDir = dataDir; + return overrides; +} + +function beeperRuntimeOverridesFromOptions(options: Map): Partial { + const overrides: Partial = {}; + const env = serverEnvOption(options); + if (env !== undefined) overrides.serverEnv = env; + return overrides; +} + +async function loadConfig(options: Map): Promise { + const configPath = stringOption(options, "config"); + if (configPath) return readConfig(configPath); + return createDefaultConfig(configOverridesFromOptions(options)); +} + +function whoamiPayload(config: OpenClawBridgeConfig): Record { + return { + appserviceId: config.appserviceId, + bridgeId: config.bridgeId ?? null, + canConnect: Boolean( + config.asToken && + config.homeserver && + config.hsToken && + config.matrixDeviceId && + config.matrixUserId + ), + deviceId: config.matrixDeviceId ?? null, + homeserver: config.homeserver ?? null, + serverEnv: config.serverEnv ?? "prod", + userId: config.matrixUserId ?? null, + }; +} + +function parseOptions(args: string[]): Map { + const options = new Map(); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg?.startsWith("--")) continue; + const key = arg.slice(2); + const next = args[index + 1]; + if (!next || next.startsWith("--")) { + options.set(key, true); + continue; + } + options.set(key, next); + index += 1; + } + return options; +} + +function stringOption(options: Map, key: string): string | undefined { + const value = options.get(key); + return typeof value === "string" ? value : undefined; +} + +function serverEnvOption(options: Map): BeeperServerEnv | undefined { + const env = stringOption(options, "server-env"); + if (env === undefined) return undefined; + if (env === "prod" || env === "staging" || env === "dev" || env === "local") return env; + throw new Error(`Invalid --server-env: ${env}`); +} + +async function promptForLoginCode(io: CliIO): Promise { + const input = io.stdin ?? process.stdin; + const rl = createInterface({ + input, + output: io.stderr as NodeJS.WritableStream, + }); + try { + const code = (await rl.question("Enter Beeper login code: ")).trim(); + if (!code) throw new Error("Missing Beeper login code"); + return code; + } finally { + rl.close(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runCli().then((code) => { + process.exitCode = code; + }); +} diff --git a/packages/openclaw/src/config.test.ts b/packages/openclaw/src/config.test.ts new file mode 100644 index 0000000..70d8775 --- /dev/null +++ b/packages/openclaw/src/config.test.ts @@ -0,0 +1,126 @@ +import { readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdtemp } from "node:fs/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import { createDefaultConfig, createConfigFromOpenClawSetup, readConfig, writeConfig } from "./config"; + +describe("OpenClaw bridge config", () => { + afterEach(() => { + delete process.env.PICKLE_OPENCLAW_DEVICE_ID; + delete process.env.PICKLE_OPENCLAW_HS_TOKEN; + delete process.env.OPENCLAW_DEVICE_ID; + }); + + it("defaults to appservice-owned non-federated bridge settings", () => { + const config = createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" }); + expect(config).toMatchObject({ + appserviceId: "sh-openclaw", + dataDir: "/tmp/openclaw-bridge", + }); + }); + + it("derives the self-hosted Beeper bridge id from the OpenClaw device id environment", () => { + process.env.PICKLE_OPENCLAW_DEVICE_ID = "OPENCLAW.DEV.123"; + expect(createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" })).toMatchObject({ + appserviceId: "sh-openclaw-openclaw-dev-123", + bridgeId: "sh-openclaw-openclaw-dev-123", + }); + }); + + it("accepts saved login and registration state from OpenClaw config", () => { + expect(createDefaultConfig({ + asToken: "as-token", + dataDir: "/tmp/openclaw-bridge", + homeserverDomain: "beeper.local", + serverEnv: "staging", + })).toMatchObject({ + asToken: "as-token", + homeserverDomain: "beeper.local", + serverEnv: "staging", + }); + }); + + it("preserves dashboard bridge identity settings through OpenClaw setup config", () => { + const config = createConfigFromOpenClawSetup({ + channels: { + beeper: { + accounts: { + "@alice:example": { + bridge: { appserviceId: "custom-openclaw" }, + dataDir: "/tmp/openclaw-bridge", + }, + }, + }, + }, + }); + + expect(config).toMatchObject({ + appserviceId: "custom-openclaw", + dataDir: "/tmp/openclaw-bridge", + }); + }); + + it("accepts only OpenClaw device id from environment variables", () => { + process.env.PICKLE_OPENCLAW_DEVICE_ID = "openclaw.device"; + process.env.PICKLE_OPENCLAW_HS_TOKEN = "ignored"; + + expect(createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" })).toMatchObject({ + appserviceId: "sh-openclaw-openclaw-device", + bridgeId: "sh-openclaw-openclaw-device", + serverEnv: "prod", + }); + expect(createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" }).hsToken).toBeUndefined(); + }); + + + it("stores config with owner-only file permissions", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-config-")); + const path = join(dir, "config.json"); + const config = createDefaultConfig({ asToken: "as-secret", dataDir: dir, homeserver: "https://matrix.example", hsToken: "hs-secret" }); + await writeConfig(config, path); + expect(JSON.parse(await readFile(path, "utf8"))).toMatchObject({ + asToken: "as-secret", + homeserver: "https://matrix.example", + hsToken: "hs-secret", + }); + expect((await stat(path)).mode & 0o777).toBe(0o600); + await expect(readConfig(path)).resolves.toMatchObject(config); + }); + + it("reads setup-shaped config from generated channels.beeper account state", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-setup-config-")); + const path = join(dir, "config.json"); + await writeFile(path, `${JSON.stringify({ + channels: { + beeper: { + accounts: { + "@alice:example": { + asToken: "as-secret", + dataDir: dir, + hsToken: "hs-secret", + serverEnv: "staging", + bridge: { + appserviceId: "sh-openclaw-device", + homeserver: "https://matrix.example", + matrixDeviceId: "DEVICE", + matrixUserId: "@alice:example", + }, + }, + }, + }, + }, + }, null, 2)}\n`); + + await expect(readConfig(path)).resolves.toMatchObject({ + appserviceId: "sh-openclaw-device", + asToken: "as-secret", + dataDir: dir, + homeserver: "https://matrix.example", + hsToken: "hs-secret", + matrixDeviceId: "DEVICE", + matrixUserId: "@alice:example", + serverEnv: "staging", + }); + }); +}); diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts new file mode 100644 index 0000000..0849ef8 --- /dev/null +++ b/packages/openclaw/src/config.ts @@ -0,0 +1,146 @@ +import { randomBytes } from "node:crypto"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { getBeeperAccountSettings, getBeeperChannelSettings, resolveDefaultBeeperAccountId, type OpenClawSetupConfig } from "./setup"; +import { requireBeeperAccountId } from "./account-id"; +import { openClawBeeperBridgeId } from "./ids"; +import type { OpenClawBridgeConfig } from "./types"; +import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime"; + +export const DEFAULT_APPSERVICE_ID = "sh-openclaw"; +export const DEFAULT_REGISTRATION_URL = "websocket"; + +export function defaultDataDir(): string { + return resolve(homedir(), ".openclaw", "pickle-bridge"); +} + +export function defaultConfigPath(dataDir = defaultDataDir()): string { + return resolve(dataDir, "config.json"); +} + +export function createDefaultConfig(overrides: Partial = {}): OpenClawBridgeConfig { + const dataDir = overrides.dataDir ?? process.env.PICKLE_OPENCLAW_DATA_DIR ?? defaultDataDir(); + const openClawDeviceId = process.env.PICKLE_OPENCLAW_DEVICE_ID ?? process.env.OPENCLAW_DEVICE_ID; + const bridgeId = + overrides.bridgeId ?? + (openClawDeviceId ? openClawBeeperBridgeId(openClawDeviceId) : undefined); + const config: OpenClawBridgeConfig = { + appserviceId: + overrides.appserviceId ?? + bridgeId ?? + DEFAULT_APPSERVICE_ID, + dataDir, + serverEnv: overrides.serverEnv ?? "prod", + }; + const asToken = overrides.asToken; + const homeserver = overrides.homeserver; + const homeserverDomain = overrides.homeserverDomain; + const hsToken = overrides.hsToken; + const matrixDeviceId = overrides.matrixDeviceId; + const matrixUserId = overrides.matrixUserId; + if (asToken) config.asToken = asToken; + if (bridgeId) config.bridgeId = bridgeId; + if (homeserver) config.homeserver = homeserver; + if (homeserverDomain) config.homeserverDomain = homeserverDomain; + if (hsToken) config.hsToken = hsToken; + if (matrixDeviceId) config.matrixDeviceId = matrixDeviceId; + if (matrixUserId) config.matrixUserId = matrixUserId; + return config; +} + +export async function readConfig(path = defaultConfigPath()): Promise { + return createDefaultConfig(configInput(JSON.parse(await readFile(path, "utf8")))); +} + +function configInput(input: unknown): Partial { + const record = recordValue(input); + const beeper = recordValue(recordValue(record?.channels)?.beeper); + if (beeper) { + const accounts = recordValue(beeper.accounts); + const defaultAccount = stringValue(beeper.defaultAccount); + const account = recordValue( + defaultAccount && accounts?.[defaultAccount] + ? accounts[defaultAccount] + : Object.values(accounts ?? {})[0], + ); + const serverEnv = normalizeServerEnv(stringValue(account?.serverEnv)); + const bridge = recordValue(account?.bridge) as Partial | undefined; + const config: Partial = { ...(bridge ?? {}) }; + const asToken = stringValue(account?.asToken); + const hsToken = stringValue(account?.hsToken); + if (serverEnv) config.serverEnv = serverEnv; + if (asToken) config.asToken = asToken; + if (hsToken) config.hsToken = hsToken; + const dataDir = stringValue(account?.dataDir); + if (dataDir) config.dataDir = dataDir; + return config; + } + return (record ?? {}) as Partial; +} + +export function createConfigFromOpenClawSetup( + cfg: OpenClawSetupConfig, + overrides: Partial = {}, + accountId?: string | null, +): OpenClawBridgeConfig { + const settings = getBeeperAccountSettings(cfg, accountId); + return createDefaultConfig({ + ...settings.bridge, + ...(typeof settings.asToken === "string" ? { asToken: settings.asToken } : {}), + ...(typeof settings.hsToken === "string" ? { hsToken: settings.hsToken } : {}), + ...(settings.serverEnv ? { serverEnv: settings.serverEnv } : {}), + ...(settings.dataDir ? { dataDir: settings.dataDir } : {}), + ...overrides, + }); +} + +export async function createRuntimeConfigFromOpenClawSetup( + cfg: OpenClawSetupConfig, + overrides: Partial = {}, + accountId?: string | null, +): Promise { + const settings = getBeeperAccountSettings(cfg, accountId); + const resolvedAccountId = requireBeeperAccountId(accountId ?? resolveDefaultBeeperAccountId(cfg)); + const accountPrefix = `channels.beeper.accounts.${resolvedAccountId}`; + const config = createConfigFromOpenClawSetup(cfg, overrides, accountId); + const asToken = await resolveConfiguredSecretInputString({ + config: cfg, + env: process.env, + value: settings.asToken, + path: `${accountPrefix}.asToken`, + }); + if (asToken.value) config.asToken = asToken.value; + const hsToken = await resolveConfiguredSecretInputString({ + config: cfg, + env: process.env, + value: settings.hsToken, + path: `${accountPrefix}.hsToken`, + }); + if (hsToken.value) config.hsToken = hsToken.value; + return config; +} + +export async function writeConfig(config: OpenClawBridgeConfig, path = defaultConfigPath(config.dataDir)): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + await chmod(path, 0o600); +} + +export function secretToken(bytes = 32): string { + return randomBytes(bytes).toString("hex"); +} + +function normalizeServerEnv(value: string | undefined): OpenClawBridgeConfig["serverEnv"] | undefined { + if (value === "prod" || value === "staging" || value === "dev" || value === "local") return value; + return undefined; +} + +function recordValue(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts new file mode 100644 index 0000000..1c96764 --- /dev/null +++ b/packages/openclaw/src/connector.test.ts @@ -0,0 +1,1455 @@ +import type { MatrixEdit, MatrixMessage, MatrixReaction, MatrixReactionRemove, MatrixRedaction, UserLogin } from "@beeper/pickle-bridge/types"; +import { describe, expect, it, vi } from "vitest"; +import { createDefaultConfig } from "./config"; +import { createOpenClawConnector, OpenClawNetworkAPI, parseMatrixTextMessage, userLoginFromOpenClawConfig } from "./connector"; +import { OpenClawPluginRuntimeAdapter, type OpenClawGatewayEvent, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; +import { OpenClawBridgeRegistry } from "./registry"; + +describe("OpenClawBridgeConnector", () => { + it("exposes bridgev2-shaped metadata and Beeper channel capabilities", async () => { + const connector = createOpenClawConnector({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + }); + expect(connector.getName()).toMatchObject({ + beeperBridgeType: "openclaw", + defaultCommandPrefix: "!openclaw", + displayName: "OpenClaw", + networkId: "openclaw", + }); + expect(connector.getCapabilities().provisioning?.resolveIdentifier).toEqual({ + contactList: true, + createDM: true, + lookupUsername: true, + search: true, + }); + expect(connector.getLoginFlows()).toEqual([]); + expect(() => connector.createLogin({} as never, { id: "@alice:example.com" }, "openclaw.gateway")).toThrow("Beeper channel runtime"); + }); + + it("keeps Beeper Matrix tokens out of OpenClaw plugin login metadata", () => { + expect(userLoginFromOpenClawConfig(createDefaultConfig({ + dataDir: "/tmp/openclaw", + }))).toMatchObject({ + id: "openclaw:plugin", + metadata: {}, + }); + }); + + it("loads the OpenClaw remote login automatically on connector start", async () => { + const connector = createOpenClawConnector({ + config: createDefaultConfig({ + dataDir: "/tmp/openclaw", + matrixUserId: "@batuhan:beeper.com", + }), + }); + const loadUserLogin = vi.fn(async () => undefined); + await connector.start({ + bridge: { loadUserLogin }, + log: vi.fn(), + } as never); + + expect(loadUserLogin).toHaveBeenCalledWith(expect.objectContaining({ + id: "openclaw:plugin", + remoteName: "OpenClaw", + userId: "@batuhan:beeper.com", + })); + }); + + it("registers the live Beeper runtime in OpenClaw channel runtime contexts", async () => { + const register = vi.fn(); + const connector = createOpenClawConnector({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw", matrixUserId: "@batuhan:beeper.com" }), + registry: new OpenClawBridgeRegistry("/tmp/openclaw-connector-runtime-context-test.json"), + runtime: { + channel: { + runtimeContexts: { register }, + }, + } as never, + }); + + await connector.init({ + bridge: { + getOwnUserId: () => "@openclaw:example.com", + }, + client: {}, + log: vi.fn(), + } as never); + + expect(register).toHaveBeenCalledWith(expect.objectContaining({ + accountId: "@batuhan:beeper.com", + capability: "beeper.runtime", + channelId: "beeper", + context: connector.getChannelRuntime(), + })); + }); + + it("loads a network API that registers OpenClaw agents as ghosts", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + responses: { + "agents.list": { agents: [{ avatarMxc: "mxc://example/codex", id: "codex", name: "Codex" }] }, + "sessions.create": { key: "agent:codex:beeper:bootstrap" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const { ctx, registerGhost } = connectContext(); + await api.connect(ctx); + expect(registerGhost).toHaveBeenCalledWith({ + avatar: { + id: "mxc://example/codex", + mxc: "mxc://example/codex", + url: "mxc://example/codex", + }, + displayName: "Codex", + id: "codex", + identifiers: ["openclaw:agent:codex", "@sh-openclaw_agent_codex:localhost"], + isBot: true, + metadata: { + openclaw: { + agentId: "codex", + avatarMxc: "mxc://example/codex", + avatarUrl: "mxc://example/codex", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + }, + }, + mxid: "@sh-openclaw_agent_codex:localhost", + profile: { + "com.beeper.openclaw.agent": { + agentId: "codex", + avatarMxc: "mxc://example/codex", + avatarUrl: "mxc://example/codex", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + }, + }, + }); + }); + + it("creates a stable welcome room for each agent without starting a session turn", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-bootstrap-test.json"); + const runtime = runtimeWith({ + responses: { + "agents.list": { agents: [ + { id: "main", name: "Main" }, + { id: "codex", name: "Codex" }, + ] }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const { createPortal, ctx, registerPortal, sendMessage } = connectContext(); + createPortal.mockImplementation(async (_login, portal) => ({ + ...portal, + mxid: portal.id === "agent:main" ? "!main:example.com" : "!codex:example.com", + portalKey: { id: portal.id, receiver: "openclaw:plugin" }, + receiver: "openclaw:plugin", + })); + + await api.connect(ctx); + + expect(createPortal).toHaveBeenCalledTimes(2); + expect(createPortal).toHaveBeenCalledWith(login(), expect.objectContaining({ + creationContent: { "m.federate": false }, + id: "agent:main", + metadata: { + openclaw: { + agentId: "main", + ghostUserId: "@sh-openclaw_agent_main:localhost", + label: "Main", + }, + }, + name: "Main", + roomType: "dm", + sender: "@sh-openclaw_agent_main:localhost", + })); + expect(createPortal).toHaveBeenCalledWith(login(), expect.objectContaining({ + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + label: "Codex", + }, + }, + name: "Codex", + roomType: "dm", + sender: "@sh-openclaw_agent_codex:localhost", + })); + expect(sendMessage).not.toHaveBeenCalled(); + expect(runtime.sendMessage).not.toHaveBeenCalled(); + expect(registerPortal).toHaveBeenCalledWith(expect.objectContaining({ + mxid: "!main:example.com", + portalKey: expect.objectContaining({ receiver: "openclaw:plugin" }), + })); + expect(registry.getBindingByRoom("!main:example.com")).toMatchObject({ + agentId: "main", + id: "agent:main", + }); + expect(registry.getBindingByRoom("!codex:example.com")).toMatchObject({ + agentId: "codex", + id: "agent:codex", + }); + }); + + it("does not create another welcome room on a later connect", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-bootstrap-repeat-test.json"); + const runtime = runtimeWith({ + responses: { + "agents.list": { agents: [{ id: "main", name: "Main" }] }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const { createPortal, ctx, sendMessage } = connectContext(); + createPortal.mockImplementation(async (_login, portal) => ({ + ...portal, + mxid: "!main:example.com", + portalKey: { id: portal.id, receiver: "openclaw:plugin" }, + receiver: "openclaw:plugin", + })); + + await api.connect(ctx); + await api.connect(ctx); + + expect(createPortal).toHaveBeenCalledOnce(); + expect(sendMessage).not.toHaveBeenCalled(); + expect(registry.getBindingById("agent:main")).toMatchObject({ + agentId: "main", + roomId: "!main:example.com", + }); + }); + + it("creates only one welcome room when connect is called concurrently", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-bootstrap-concurrent-test.json"); + const runtime = runtimeWith({ + responses: { + "agents.list": { agents: [] }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const { createPortal, ctx, sendMessage } = connectContext(); + let unblockCreatePortal!: () => void; + createPortal.mockImplementationOnce(async (_login, portal) => { + await new Promise((resolve) => { unblockCreatePortal = resolve; }); + return { + ...portal, + mxid: "!bootstrap:example.com", + portalKey: { id: portal.id, receiver: "openclaw:plugin" }, + receiver: "openclaw:plugin", + }; + }); + + const first = api.connect(ctx); + const second = api.connect(ctx); + await vi.waitFor(() => expect(createPortal).toHaveBeenCalledOnce()); + unblockCreatePortal(); + await Promise.all([first, second]); + + expect(createPortal).toHaveBeenCalledOnce(); + expect(sendMessage).not.toHaveBeenCalled(); + expect(registry.getBindingByRoom("!bootstrap:example.com")).toBeDefined(); + }); + + it("still creates an agent welcome room when only a session room exists", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-bootstrap-existing-test.json"); + registry.upsertBinding({ + agentId: "main", + createdAt: 1, + ghostUserId: "@main:example.com", + id: "existing", + roomId: "!existing:example.com", + sessionKey: "agent:main:existing", + updatedAt: 1, + }); + const runtime = runtimeWith({ + responses: { "agents.list": { agents: [] } }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const { createPortal, ctx, sendMessage } = connectContext(); + + await api.connect(ctx); + + expect(createPortal).toHaveBeenCalledWith(login(), expect.objectContaining({ + id: "agent:main", + metadata: { + openclaw: { + agentId: "main", + ghostUserId: "@sh-openclaw_agent_main:localhost", + label: "main", + }, + }, + })); + expect(sendMessage).not.toHaveBeenCalled(); + expect(runtime.sendMessage).not.toHaveBeenCalled(); + expect(registry.getBindingsByAgent("main")).toHaveLength(2); + }); + + it("registers current agent ghosts only", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + registry.upsertAgent({ agentId: "codex", displayName: "Codex", ghostUserId: "@codex:example.com" }); + const runtime = runtimeWith({ + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + "sessions.create": { key: "agent:codex:beeper:bootstrap" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + }); + const { ctx, registerGhost } = connectContext(); + await api.connect(ctx); + expect(registerGhost).toHaveBeenCalledWith(expect.objectContaining({ id: "codex", mxid: "@sh-openclaw_agent_codex:localhost" })); + }); + + it("keeps existing agent room bindings aligned to the latest ghost profile", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-ghost-sync-test.json"); + registry.upsertBinding({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@old-codex:example.com", + id: "agent:codex", + label: "Old Codex", + roomId: "!codex:example.com", + updatedAt: 1, + }); + const runtime = runtimeWith({ + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + }); + const { ctx } = connectContext(); + + await api.connect(ctx); + + expect(registry.getBindingById("agent:codex")).toMatchObject({ + ghostUserId: "@sh-openclaw_agent_codex:localhost", + label: "Codex", + }); + }); + + it("resolves agent identifiers into DM portals", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + registry.upsertAgent({ agentId: "codex", displayName: "Codex", ghostUserId: "@codex:example.com" }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime: runtimeWith({ responses: { "agents.list": { agents: [{ id: "codex", name: "Codex" }] } } }), + }); + await expect(api.resolveIdentifier({ bridge: { createPortal: vi.fn() } } as unknown as BridgeRequestContext, { + createDM: false, + identifier: "codex", + type: "username", + })).resolves.toEqual({ + ghost: codexGhost(), + userId: "@sh-openclaw_agent_codex:localhost", + }); + + const createPortal = vi.fn(async (loginArg, options) => ({ + id: options.id, + metadata: options.metadata, + mxid: "!codex-dm:example.com", + portalKey: { id: options.id, receiver: loginArg.id }, + receiver: loginArg.id, + })); + await expect(api.resolveIdentifier({ bridge: { createPortal } } as unknown as BridgeRequestContext, { + createDM: true, + identifier: "codex", + type: "username", + })).resolves.toMatchObject({ + ghost: { + displayName: "Codex", + id: "codex", + metadata: { + openclaw: { + agentId: "codex", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + }, + }, + mxid: "@sh-openclaw_agent_codex:localhost", + }, + portal: { + id: expect.stringMatching(/^conversation:/), + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + label: "Codex", + }, + }, + portalKey: { id: expect.stringMatching(/^conversation:/), receiver: "openclaw:plugin" }, + receiver: "openclaw:plugin", + roomType: "dm", + mxid: "!codex-dm:example.com", + }, + userId: "@sh-openclaw_agent_codex:localhost", + }); + expect(createPortal).toHaveBeenCalledWith(login(), expect.objectContaining({ + creationContent: { "m.federate": false }, + id: expect.stringMatching(/^conversation:/), + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + label: "Codex", + }, + }, + name: "Codex", + roomType: "dm", + sender: "@sh-openclaw_agent_codex:localhost", + })); + expect(registry.getBindingByRoom("!codex-dm:example.com")).toMatchObject({ + agentId: "codex", + roomId: "!codex-dm:example.com", + }); + }); + + it("does not synthesize Beeper DMs for unknown OpenClaw agents", async () => { + const runtime = runtimeWith({ + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry: new OpenClawBridgeRegistry("/tmp/openclaw-connector-unknown-agent-test.json"), + runtime, + }); + const createPortal = vi.fn(); + + await expect(api.resolveIdentifier({ bridge: { createPortal } } as unknown as BridgeRequestContext, { + createDM: true, + identifier: "not-an-agent", + type: "username", + })).resolves.toEqual({}); + + expect(createPortal).not.toHaveBeenCalled(); + }); + + it("creates a fresh DM portal even when the same agent already has a room", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-existing-dm-test.json"); + registry.upsertAgent({ agentId: "codex", displayName: "Codex", ghostUserId: "@codex:example.com" }); + registry.upsertBinding({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example.com", + id: "existing", + roomId: "!existing-codex-dm:example.com", + updatedAt: 1, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime: runtimeWith({ responses: { "agents.list": { agents: [{ id: "codex", name: "Codex" }] } } }), + }); + const createPortal = vi.fn(async (loginArg, options) => ({ + id: options.id, + metadata: options.metadata, + mxid: "!second-codex-dm:example.com", + portalKey: { id: options.id, receiver: loginArg.id }, + receiver: loginArg.id, + })); + + await expect(api.resolveIdentifier({ bridge: { createPortal } } as unknown as BridgeRequestContext, { + createDM: true, + identifier: "codex", + type: "username", + })).resolves.toMatchObject({ + portal: { + id: expect.stringMatching(/^conversation:/), + mxid: "!second-codex-dm:example.com", + portalKey: { id: expect.stringMatching(/^conversation:/), receiver: "openclaw:plugin" }, + }, + userId: "@sh-openclaw_agent_codex:localhost", + }); + expect(createPortal).toHaveBeenCalledOnce(); + expect(registry.getBindingsByAgent("codex")).toHaveLength(2); + }); + + it("lists searchable OpenClaw agent contacts for Beeper contact lists", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + responses: { + "agents.list": { + agents: [ + { id: "codex", name: "Codex" }, + { id: "planner", name: "Planner" }, + ], + }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + + await expect(api.listContacts({} as BridgeRequestContext, { query: "code" })).resolves.toEqual({ + contacts: [{ + ghost: codexGhost(), + userId: "@sh-openclaw_agent_codex:localhost", + }], + }); + }); + + it("searches OpenClaw agent contacts for BridgeV2 user search", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-search-test.json"); + const runtime = runtimeWith({ + responses: { + "agents.list": { + agents: [ + { id: "codex", name: "Codex" }, + { id: "planner", name: "Planner" }, + ], + }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + + await expect(api.searchUsers({} as BridgeRequestContext, { query: "plan" })).resolves.toEqual({ + results: [{ + ghost: expect.objectContaining({ + displayName: "Planner", + id: "planner", + identifiers: ["openclaw:agent:planner", "@sh-openclaw_agent_planner:localhost"], + isBot: true, + mxid: "@sh-openclaw_agent_planner:localhost", + }), + userId: "@sh-openclaw_agent_planner:localhost", + }], + }); + }); + + it("lists current agent contacts", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-contacts-test.json"); + const runtime = runtimeWith({ + responses: { + "agents.list": { + agents: [{ id: "codex", name: "Codex" }], + }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + }); + + await expect(api.listContacts({} as BridgeRequestContext, { query: "codex" })).resolves.toEqual({ + contacts: [{ + ghost: codexGhost(), + userId: "@sh-openclaw_agent_codex:localhost", + }], + }); + }); + + it("drops bridge-owned ghost senders before forwarding to OpenClaw", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + registry.upsertAgent({ agentId: "codex", displayName: "Codex", ghostUserId: "@codex:example.com" }); + const runtime = runtimeWith({ + responses: { + "sessions.create": { key: "agent:codex:session_1" }, + "beeper.turn": { runId: "run_1", sessionKey: "agent:codex:session_1" }, + }, + }); + runtime.config.matrixUserId = "@sh-openclawbot:example.com"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + }); + const portal = { + id: "agent:codex", + metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com" } }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$alice" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "hello", + } as MatrixMessage); + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$mallory" }, + portal, + sender: { userId: "@mallory:example.com" }, + text: "hello", + } as MatrixMessage); + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$ghost" }, + portal, + sender: { userId: "@codex:example.com" }, + text: "hello", + } as MatrixMessage); + + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$alice", + sessionKey: "agent:codex:session_1", + })); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$mallory", + sessionKey: "agent:codex:session_1", + })); + expect(runtime.sendMessage).not.toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$ghost", + })); + }); + + it("accepts the Beeper owner MXID as a sender in self-hosted rooms", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-owner-sender-test.json"); + const runtime = runtimeWith({ + events: [{ event: "run.completed", payload: { runId: "run_owner", type: "run.completed" } }], + responses: { + "sessions.create": { key: "agent:main:owner" }, + "beeper.turn": { runId: "run_owner", sessionKey: "agent:main:owner" }, + }, + }); + runtime.config.matrixUserId = "@owner:beeper-staging.com"; + runtime.config.homeserverDomain = "beeper.local"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + }); + const roomId = "!owner-room:beeper.local"; + + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$owner" }, + portal: { + id: roomId, + mxid: roomId, + portalKey: { id: roomId }, + }, + sender: { userId: "@owner:beeper-staging.com" }, + text: "hello from owner", + } as MatrixMessage); + + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", expect.objectContaining({ + agentId: "main", + })); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + message: "hello from owner", + sessionKey: "agent:main:owner", + })); + }); + + it("dispatches Matrix text and native approval responses to OpenClaw", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + events: [{ event: "run.completed", payload: { runId: "run_1", type: "run.completed" } }], + responses: { + "exec.approval.resolve": { ok: true }, + "sessions.create": { key: "agent:codex:session_1" }, + "beeper.turn": { runId: "run_1", sessionKey: "agent:codex:session_1" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const portal = { + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + }, + }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + const queueRemoteEvent = vi.fn(); + await expect(api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { + event: { eventId: "$message" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "hello", + } as MatrixMessage)).resolves.toEqual({ pending: false }); + expect(runtime.sendMessage).toHaveBeenCalledWith({ + idempotencyKey: "$message", + matrix: { + roomId: "!room:example.com", + sender: "@alice:example.com", + }, + message: "hello", + sessionKey: "agent:codex:session_1", + }); + expect(registry.getBindingByRoom("!room:example.com")).toMatchObject({ + agentId: "codex", + sessionKey: "agent:codex:session_1", + }); + + await expect(api.handleMatrixReaction({} as BridgeRequestContext, { + content: { + "m.relates_to": { event_id: "approval_1", key: "approval.deny" }, + }, + event: { eventId: "$reaction" }, + portal, + targetMessage: { id: "approval_1" }, + } as MatrixReaction)).resolves.toEqual({ + id: "$reaction", + metadata: { + openclaw: { + approval: { + approvalId: "approval_1", + approved: false, + approvedAlways: false, + decision: "deny", + }, + ignored: "approval-reactions-disabled", + }, + }, + }); + expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1", + decision: "deny", + }); + + await expect(api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { + content: { + approvalId: "approval_2", + approved: true, + approvedAlways: true, + toolCallId: "tool_1", + type: "tool-approval-response", + }, + event: { eventId: "$native-approval" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "Approved", + } as MatrixMessage)).resolves.toEqual({ pending: false }); + expect(runtime.transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_2", + decision: "approve_always", + toolCallId: "tool_1", + }); + expect(runtime.sendMessage).not.toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$native-approval", + })); + }); + + it("handles OpenClaw slash commands as normal agent turns without Matrix side notices", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-slash-command-test.json"); + const runtime = runtimeWith({ + responses: { + "sessions.create": { key: "agent:codex:session_1" }, + "beeper.turn": { runId: "run_1", sessionKey: "agent:codex:session_1" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const { ctx, sendMessage } = connectContext(); + const portal = { + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + }, + }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + await expect(api.handleMatrixMessage(ctx as unknown as BridgeRequestContext, { + content: { body: "/session", msgtype: "m.text" }, + event: { eventId: "$session-command" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/session", + } as MatrixMessage)).resolves.toEqual({ pending: false }); + + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$session-command", + matrix: expect.objectContaining({ + command: { args: "", name: "session" }, + roomId: "!room:example.com", + sender: "@alice:example.com", + }), + message: "/session", + sessionKey: "agent:codex:session_1", + })); + expect(sendMessage).not.toHaveBeenCalled(); + expect(registry.getBindingByRoom("!room:example.com")).toMatchObject({ + sessionKey: "agent:codex:session_1", + }); + }); + + it("parses Matrix replies and slash commands for OpenClaw turns", async () => { + expect(parseMatrixTextMessage("> <@alice> old\n\nnew text", { + "m.relates_to": { + "m.in_reply_to": { event_id: "$old" }, + }, + })).toEqual({ + attachments: [], + replyQuote: { + body: "old", + sender: "@alice", + }, + replyToEventId: "$old", + text: "new text", + }); + expect(parseMatrixTextMessage("/stop", {})).toEqual({ + attachments: [], + command: { args: "", name: "stop" }, + text: "/stop", + }); + expect(parseMatrixTextMessage("@bot:example.com /status", {})).toEqual({ + attachments: [], + command: { args: "", name: "status" }, + text: "@bot:example.com /status", + }); + expect(parseMatrixTextMessage("photo", { + "m.mentions": { room: true, user_ids: ["@bob:example.com"] }, + formatted_body: "photo", + msgtype: "m.image", + url: "mxc://example/photo", + }, { + attachments: [{ contentType: "image/png", contentUri: "mxc://example/photo", filename: "photo.png", height: 10, kind: "image", size: 12, width: 10 }], + event: { html: "photo", mentions: { room: true, userIds: ["@bob:example.com"] }, threadRoot: "$thread" }, + threadRoot: { id: "$thread-message" }, + } as never)).toEqual({ + attachments: [{ + contentType: "image/png", + contentUri: "mxc://example/photo", + filename: "photo.png", + height: 10, + kind: "image", + size: 12, + width: 10, + }], + formattedBody: "photo", + mentions: { room: true, userIds: ["@bob:example.com"] }, + text: "photo", + threadRootEventId: "$thread-message", + }); + expect(parseMatrixTextMessage("* old text", { + "m.new_content": { + body: "corrected", + formatted_body: "corrected", + msgtype: "m.text", + }, + "m.relates_to": { + event_id: "$old", + rel_type: "m.replace", + }, + formatted_body: "* old text", + })).toEqual({ + attachments: [], + formattedBody: "corrected", + text: "corrected", + }); + expect(parseMatrixTextMessage("> <@alice> old\n\nnew text", { + "m.relates_to": { + "m.in_reply_to": { event_id: "$old" }, + }, + formatted_body: '
In reply
old
new text', + })).toEqual({ + attachments: [], + formattedBody: "new text", + replyQuote: { + body: "old", + sender: "@alice", + }, + replyToEventId: "$old", + text: "new text", + }); + + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + registry.upsertBinding({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example.com", + id: "binding-reply", + lastRunId: "run_previous", + lastStreamRunId: "run_previous", + lastStreamTargetEventId: "$old", + roomId: "!room:example.com", + sessionKey: "agent:codex:session_2", + updatedAt: 1, + }); + const runtime = runtimeWith({ + events: [{ event: "run.completed", payload: { runId: "run_2", type: "run.completed" } }], + responses: { + "sessions.create": { key: "agent:codex:session_2" }, + "beeper.turn": { runId: "run_2", sessionKey: "agent:codex:session_2" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const portal = { + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + }, + }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + await api.handleMatrixMessage({} as BridgeRequestContext, { + attachments: [{ contentType: "image/png", contentUri: "mxc://example/photo", filename: "photo.png", kind: "image" }], + content: { + "m.relates_to": { + "m.in_reply_to": { event_id: "$old" }, + }, + }, + event: { eventId: "$reply" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "> <@alice> old\n\nnew text", + } as MatrixMessage); + expect(runtime.sendMessage).toHaveBeenCalledWith({ + attachments: [{ contentType: "image/png", contentUri: "mxc://example/photo", filename: "photo.png", kind: "image" }], + idempotencyKey: "$reply", + matrix: { + attachments: [{ contentType: "image/png", contentUri: "mxc://example/photo", filename: "photo.png", kind: "image" }], + relation: { + kind: "reply", + quote: { + body: "old", + sender: "@alice", + }, + replyToEventId: "$old", + targetRunId: "run_previous", + targetSessionKey: "agent:codex:session_2", + }, + roomId: "!room:example.com", + sender: "@alice:example.com", + }, + message: "new text", + replyTo: { eventId: "$old", roomId: "!room:example.com" }, + sessionKey: "agent:codex:session_2", + }); + + await api.handleMatrixMessage({} as BridgeRequestContext, { + content: {}, + event: { eventId: "$status" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/status", + } as MatrixMessage); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$status", + matrix: expect.objectContaining({ + command: { args: "", name: "status" }, + roomId: "!room:example.com", + sender: "@alice:example.com", + }), + message: "/status", + sessionKey: "agent:codex:session_2", + })); + }); + + it("passes Matrix formatted body, mentions, and thread metadata to OpenClaw", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + events: [{ event: "run.completed", payload: { runId: "run_thread", type: "run.completed" } }], + responses: { + "sessions.create": { key: "agent:codex:session_thread" }, + "beeper.turn": { runId: "run_thread", sessionKey: "agent:codex:session_thread" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + + await api.handleMatrixMessage({} as BridgeRequestContext, { + content: { + "m.mentions": { room: true, user_ids: ["@bob:example.com"] }, + "m.relates_to": { + event_id: "$thread-root", + rel_type: "m.thread", + }, + formatted_body: "hello", + }, + event: { eventId: "$thread-message" }, + portal: { + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + }, + }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }, + sender: { userId: "@alice:example.com" }, + text: "hello", + } as MatrixMessage); + + expect(runtime.sendMessage).toHaveBeenCalledWith({ + idempotencyKey: "$thread-message", + matrix: { + formattedBody: "hello", + mentions: { room: true, userIds: ["@bob:example.com"] }, + relation: { + kind: "thread", + replyToEventId: "$thread-root", + threadRootEventId: "$thread-root", + }, + roomId: "!room:example.com", + sender: "@alice:example.com", + threadRootEventId: "$thread-root", + }, + message: "hello", + replyTo: { eventId: "$thread-root", roomId: "!room:example.com" }, + sessionKey: "agent:codex:session_thread", + }); + }); + + it("forwards Matrix edits, redactions, and non-approval reactions as session context", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + registry.upsertBinding({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example.com", + id: "binding-relations", + lastRunId: "run_streamed", + lastStreamRunId: "run_streamed", + lastStreamTargetEventId: "$old", + roomId: "!room:example.com", + sessionKey: "agent:codex:session_1", + updatedAt: 1, + }); + const runtime = runtimeWith({ + events: [ + { event: "run.completed", payload: { runId: "run_edit", type: "run.completed" } }, + { event: "run.completed", payload: { runId: "run_reaction", type: "run.completed" } }, + { event: "run.completed", payload: { runId: "run_redaction", type: "run.completed" } }, + ], + responses: { + "sessions.create": { key: "agent:codex:session_1" }, + "beeper.turn": { runId: "run_edit", sessionKey: "agent:codex:session_1" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const portal = { + id: "agent:codex", + metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com" } }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + await api.handleMatrixEdit({} as BridgeRequestContext, { + content: { + "m.new_content": { + body: "corrected", + formatted_body: "corrected", + msgtype: "m.text", + }, + "m.relates_to": { + event_id: "$old", + rel_type: "m.replace", + }, + }, + event: { eventId: "$edit" }, + existing: [], + portal, + sender: { userId: "@alice:example.com" }, + targetMessage: { id: "$old" }, + text: "* typo", + } as MatrixEdit); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$edit:edit", + matrix: { + formattedBody: "corrected", + relation: { + kind: "edit", + targetEventId: "$old", + targetRunId: "run_streamed", + targetSessionKey: "agent:codex:session_1", + }, + roomId: "!room:example.com", + sender: "@alice:example.com", + }, + message: "corrected", + replyTo: { eventId: "$old", roomId: "!room:example.com" }, + })); + + await expect(api.handleMatrixReaction({} as BridgeRequestContext, { + content: { "m.relates_to": { event_id: "$old", key: "👍", rel_type: "m.annotation" } }, + event: { eventId: "$react", sender: "@alice:example.com" }, + portal, + targetMessage: { id: "$old" }, + } as MatrixReaction)).resolves.toEqual({ + id: "$react", + metadata: { openclaw: { reaction: "👍", targetMessageId: "$old" } }, + }); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$react", + matrix: { + relation: { + key: "👍", + kind: "reaction", + targetEventId: "$old", + targetRunId: "run_streamed", + targetSessionKey: "agent:codex:session_1", + }, + roomId: "!room:example.com", + sender: "@alice:example.com", + }, + message: "Reacted 👍 to $old", + replyTo: { eventId: "$old", roomId: "!room:example.com" }, + })); + + await api.handleMatrixReactionRemove({} as BridgeRequestContext, { + content: { "m.relates_to": { event_id: "$old", key: "👍", rel_type: "m.annotation" } }, + event: { eventId: "$react-redact", sender: "@alice:example.com" }, + portal, + targetMessage: { id: "$old" }, + targetReaction: { id: "$react" }, + } as MatrixReactionRemove); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$react-redact", + matrix: { + relation: { + key: "👍", + kind: "reaction_remove", + targetEventId: "$old", + targetReactionId: "$react", + targetRunId: "run_streamed", + targetSessionKey: "agent:codex:session_1", + }, + roomId: "!room:example.com", + sender: "@alice:example.com", + }, + message: "Removed reaction 👍 from $old", + replyTo: { eventId: "$old", roomId: "!room:example.com" }, + })); + + await api.handleMatrixRedaction({} as BridgeRequestContext, { + eventId: "$redact", + portal, + targetMessage: { id: "$old" }, + } as MatrixRedaction); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$redact", + matrix: { + relation: { + kind: "redaction", + targetEventId: "$old", + targetRunId: "run_streamed", + targetSessionKey: "agent:codex:session_1", + }, + roomId: "!room:example.com", + sender: "redaction", + }, + message: "Redacted message $old", + replyTo: { eventId: "$old", roomId: "!room:example.com" }, + })); + }); + + it("auto-binds unbound Beeper rooms before forwarding chat turns", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + responses: { + "sessions.create": { key: "agent:main:auto" }, + "beeper.turn": { runId: "run_auto", sessionKey: "agent:main:auto" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + }); + const log = vi.fn(); + const registerPortal = vi.fn(); + const ctx = { bridge: { registerPortal }, log, queueRemoteEvent: vi.fn() } as unknown as BridgeRequestContext; + const portal = { + id: "!cloud-room:example.com", + mxid: "!cloud-room:example.com", + portalKey: { id: "!cloud-room:example.com", receiver: "login" }, + receiver: "login", + }; + + await api.handleMatrixMessage(ctx, { + event: { eventId: "$hello" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "hey", + } as MatrixMessage); + + expect(log).toHaveBeenCalledWith("warn", "openclaw_matrix_message_unbound_room", expect.objectContaining({ + roomId: "!cloud-room:example.com", + })); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", expect.objectContaining({ + agentId: "main", + label: "New OpenClaw Session", + })); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$hello", + message: "hey", + sessionKey: "agent:main:auto", + })); + expect(registry.getBindingByRoom("!cloud-room:example.com")).toMatchObject({ + agentId: "main", + label: "New OpenClaw Session", + sessionKey: "agent:main:auto", + }); + expect(registerPortal).toHaveBeenCalledWith(expect.objectContaining({ + id: "!cloud-room:example.com", + metadata: { + openclaw: { + agentId: "main", + ghostUserId: "@sh-openclaw_agent_main:localhost", + label: "New OpenClaw Session", + sessionKey: "agent:main:auto", + }, + }, + mxid: "!cloud-room:example.com", + portalKey: { + id: "!cloud-room:example.com", + receiver: "openclaw:plugin", + }, + receiver: "openclaw:plugin", + })); + }); + + it("rejects reaction approvals and forwards slash approval text as regular turns", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + responses: { + "exec.approval.resolve": { ok: true }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + }); + const portal = { + id: "agent:codex", + metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", sessionKey: "agent:codex:session_1" } }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + await expect(api.handleMatrixReaction({} as BridgeRequestContext, { + content: { "m.relates_to": { event_id: "approval_1", key: "approval.deny" } }, + event: { eventId: "$reaction" }, + portal, + targetMessage: { id: "approval_1" }, + } as MatrixReaction)).resolves.toMatchObject({ + metadata: { openclaw: { ignored: "approval-reactions-disabled" } }, + }); + expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", expect.anything()); + + await api.handleMatrixMessage({} as BridgeRequestContext, { + content: { + approvalId: "approval_native", + approved: true, + type: "tool-approval-response", + }, + event: { eventId: "$native" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "Approved", + } as MatrixMessage); + expect(runtime.transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_native", + decision: "approve", + }); + expect(runtime.sendMessage).not.toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$native", + })); + + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$approve" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/approve approval_1", + } as MatrixMessage); + expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1", + decision: "approve", + }); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$approve", + message: "/approve approval_1", + sessionKey: "agent:codex:session_1", + })); + + await api.handleMatrixMessage({} as BridgeRequestContext, { + content: { + "m.relates_to": { + "m.in_reply_to": { event_id: "approval_1_reply" }, + }, + }, + event: { eventId: "$deny-reply" }, + portal, + replyTo: { id: "approval_1_reply" }, + sender: { userId: "@alice:example.com" }, + text: "/deny", + } as MatrixMessage); + expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1_reply", + decision: "deny", + }); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$deny-reply", + message: "/deny", + sessionKey: "agent:codex:session_1", + })); + + }); + +}); + +function login(): UserLogin { + return { id: "openclaw:plugin", metadata: {}, userId: "@alice:example.com" }; +} + +function codexGhost() { + const contact = { + agentId: "codex", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + }; + return { + displayName: "Codex", + id: "codex", + identifiers: ["openclaw:agent:codex", "@sh-openclaw_agent_codex:localhost"], + isBot: true, + metadata: { openclaw: contact }, + mxid: "@sh-openclaw_agent_codex:localhost", + profile: { "com.beeper.openclaw.agent": contact }, + }; +} + +function connectContext() { + const registerGhost = vi.fn(async () => {}); + const registerPortal = vi.fn(); + const createPortal = vi.fn(async (_login: UserLogin, portal: { id: string; metadata?: unknown; portalKey?: { id: string; receiver?: string } }) => ({ + ...portal, + mxid: "!bootstrap:example.com", + portalKey: { id: portal.id, receiver: "openclaw:plugin" }, + receiver: "openclaw:plugin", + })); + const sendMessage = vi.fn(async () => ({ + eventId: "$bootstrap", + raw: {}, + roomId: "!bootstrap:example.com", + })); + return { + createPortal, + ctx: { + bridge: { createPortal, registerGhost, registerPortal }, + client: { appservice: { sendMessage } }, + log: vi.fn(), + queue: vi.fn(), + queueRemoteEvent: vi.fn(), + } as unknown as Parameters[0], + registerGhost, + registerPortal, + sendMessage, + }; +} + +function runtimeWith(options: { + events?: OpenClawGatewayEvent[]; + responses: Record; +}): OpenClawPluginRuntimeAdapter & { + sendMessage: ReturnType; + transport: OpenClawRuntimeRequestSurface & { request: ReturnType }; +} { + const transport = { + async *events(filter?: (event: OpenClawGatewayEvent) => boolean) { + for (const event of options.events ?? []) { + if (!filter || filter(event)) yield event; + } + }, + request: vi.fn(async (method: string) => options.responses[method]), + }; + const runtime = new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }) as OpenClawPluginRuntimeAdapter & { + sendMessage: ReturnType; + transport: OpenClawRuntimeRequestSurface & { request: ReturnType }; + }; + runtime.sendMessage = vi.fn(async (params: { sessionKey: string }) => { + const response = options.responses["beeper.turn"]; + if (response instanceof Error) throw response; + return response ?? { runId: "run_1", sessionKey: params.sessionKey }; + }); + return runtime; +} diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts new file mode 100644 index 0000000..e27eb85 --- /dev/null +++ b/packages/openclaw/src/connector.ts @@ -0,0 +1,967 @@ +import { + randomUUID, +} from "node:crypto"; +import { + BridgeConnector, + BridgeContext, + BridgeRequestContext, + BridgeUser, + ConnectContext, + type ContactListingNetworkAPI, + type EditHandlingNetworkAPI, + IdentifierResolvingNetworkAPI, + type ListContactsParams, + type ListContactsResponse, + LoginCreateContext, + LoginFlow, + LoginProcess, + LoadUserLoginContext, + MatrixEdit, + MatrixMessage, + MatrixMessageResponse, + MatrixReaction, + MatrixReactionRemove, + MatrixRedaction, + MatrixReadReceipt, + MatrixMarkedUnread, + MatrixDeleteChat, + MatrixMembership, + MatrixRoomAvatar, + MatrixRoomName, + MatrixRoomTopic, + MatrixTyping, + MessageHandlingNetworkAPI, + type DeleteChatHandlingNetworkAPI, + type MarkedUnreadHandlingNetworkAPI, + type MembershipHandlingNetworkAPI, + NetworkAPI, + NetworkGeneralCapabilities, + Portal, + type Avatar, + type PortalKey, + ReactionHandlingNetworkAPI, + type ReadReceiptHandlingNetworkAPI, + type ReactionRemoveHandlingNetworkAPI, + type RedactionHandlingNetworkAPI, + type RoomAvatarHandlingNetworkAPI, + type RoomNameHandlingNetworkAPI, + type RoomTopicHandlingNetworkAPI, + type TypingHandlingNetworkAPI, + Reaction, + ResolveIdentifierParams, + ResolveIdentifierResponse, + type SearchUsersParams, + type SearchUsersResponse, + UserLogin, + type UserSearchingNetworkAPI, +} from "@beeper/pickle-bridge/types"; +import { parseApprovalReactionContent, parseApprovalResponseContent } from "./approval"; +import { + BEEPER_CHANNEL_RUNTIME_CONTEXT_CAPABILITY, + BeeperChannelRuntime, + setBeeperChannelRuntimeForHost, +} from "./beeper-channel-runtime"; +import { beeperAccountIdFromMatrixUserId } from "./account-id"; +import { OpenClawMatrixBridgeAgent } from "./bridge-agent"; +import { createDefaultConfig } from "./config"; +import { parseMatrixTextMessage, type ParsedMatrixTextMessage } from "./matrix-parser"; +import { + createOpenClawHostRuntimeAdapter, + type OpenClawBridgeRuntime, + OpenClawPluginRuntimeAdapter, + OpenClawHostRuntimeAdapter, + type OpenClawHostRuntime, + type OpenClawMatrixMessageMetadata, + type OpenClawRunRef, + type OpenClawSessionSendOptions, +} from "./openclaw-runtime"; +import { OpenClawBridgeRegistry } from "./registry"; +import { agentContactFromOpenClawAgent, agentGhostUserId, serviceBotUserId } from "./rooms"; +import { matrixDomainFromHomeserver } from "./rooms"; +import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding } from "./types"; + +const DEFAULT_NEW_SESSION_LABEL = "New OpenClaw Session"; + +export interface OpenClawConnectorOptions { + config?: OpenClawBridgeConfig; + onActivity?: (patch: OpenClawBridgeActivityPatch) => void; + registry?: OpenClawBridgeRegistry; + runtime?: OpenClawPluginRuntimeAdapter | OpenClawHostRuntime; + runtimeFactory?: (config: OpenClawBridgeConfig) => OpenClawPluginRuntimeAdapter; +} + +export type OpenClawBridgeActivityPatch = { + lastEventAt?: number; + lastInboundAt?: number; + lastOutboundAt?: number; + lastTransportActivityAt?: number; +}; + +export function createOpenClawConnector(options: OpenClawConnectorOptions = {}): OpenClawBridgeConnector { + return new OpenClawBridgeConnector(options); +} + +export class OpenClawBridgeConnector implements BridgeConnector { + readonly config: OpenClawBridgeConfig; + readonly registry: OpenClawBridgeRegistry; + readonly runtime: OpenClawPluginRuntimeAdapter | undefined; + readonly #hostRuntime: OpenClawHostRuntime | undefined; + #channelRuntime: BeeperChannelRuntime | undefined; + #onActivity: ((patch: OpenClawBridgeActivityPatch) => void) | undefined; + #runtimeFactory: (config: OpenClawBridgeConfig) => OpenClawPluginRuntimeAdapter; + + constructor(options: OpenClawConnectorOptions = {}) { + this.config = options.config ?? createDefaultConfig(); + this.registry = options.registry ?? new OpenClawBridgeRegistry(); + this.#onActivity = options.onActivity; + this.#hostRuntime = options.runtime && !(options.runtime instanceof OpenClawPluginRuntimeAdapter) + ? options.runtime + : undefined; + const runtime = options.runtime instanceof OpenClawPluginRuntimeAdapter + ? options.runtime + : this.#hostRuntime + ? new OpenClawPluginRuntimeAdapter({ config: this.config, transport: createOpenClawHostRuntimeAdapter(this.#hostRuntime) }) + : undefined; + this.runtime = runtime; + this.#runtimeFactory = + options.runtimeFactory ?? + ((config) => { + if (runtime) return runtime; + throw new Error("OpenClaw host runtime is required"); + }); + } + + getChannelRuntime(): BeeperChannelRuntime | undefined { + return this.#channelRuntime; + } + + getName() { + return { + beeperBridgeType: "openclaw", + defaultCommandPrefix: "!openclaw", + displayName: "OpenClaw", + networkId: "openclaw", + networkUrl: "https://github.com/openclaw/openclaw", + }; + } + + getBridgeInfoVersion() { + return { capabilities: 1, info: 1 }; + } + + getConfig() { + return { data: this.config }; + } + + getDBMetaTypes() { + return { + ghost: () => ({}), + portal: () => ({}), + userLogin: () => ({}), + }; + } + + getCapabilities(): NetworkGeneralCapabilities { + return { + native: true, + provisioning: { + resolveIdentifier: { + contactList: true, + createDM: true, + lookupUsername: true, + search: true, + }, + }, + }; + } + + getLoginFlows(): LoginFlow[] { + return []; + } + + async init(ctx: BridgeContext): Promise { + await this.registry.load(); + const ownUserId = ctx.bridge.getOwnUserId(); + const login = userLoginFromOpenClawConfig(this.config); + const channelRuntime = new BeeperChannelRuntime({ + bridge: ctx.bridge, + getAgents: () => this.registry.data.agents, + getBindingByRoom: (roomId) => this.registry.getBindingByRoom(roomId), + getBindingBySessionKey: (sessionKey) => this.registry.getBindingBySessionKey(sessionKey), + login, + log: (level, message, data) => ctx.log(level, message, data), + ...(this.#onActivity ? { onActivity: this.#onActivity } : {}), + ...(ownUserId ? { userId: ownUserId } : {}), + }); + this.#channelRuntime = channelRuntime; + if (this.#hostRuntime) setBeeperChannelRuntimeForHost(this.#hostRuntime, channelRuntime); + registerBeeperRuntimeContext(this.#hostRuntime, channelRuntime, this.config); + } + + async start(ctx: BridgeContext): Promise { + await this.registry.save(); + const login = userLoginFromOpenClawConfig(this.config); + try { + await ctx.bridge.loadUserLogin(login); + } catch (error: unknown) { + ctx.log("warn", "openclaw_default_login_load_failed", { error, loginId: login.id }); + } + } + + createLogin(_ctx: LoginCreateContext, _user: BridgeUser, flowId: string): LoginProcess { + throw new Error(`Unsupported OpenClaw login flow for Beeper channel runtime: ${flowId}`); + } + + loadUserLogin(_ctx: LoadUserLoginContext, login: UserLogin): NetworkAPI { + return new OpenClawNetworkAPI({ + config: this.config, + login, + ...(this.#onActivity ? { onActivity: this.#onActivity } : {}), + registry: this.registry, + runtime: this.#runtimeFactory(this.config), + sendTurn: this.#sendTurn, + }); + } + + #sendTurn = (options: OpenClawSessionSendOptions) => { + const runtime = this.#runtimeFactory(this.config); + if (runtime.transport instanceof OpenClawHostRuntimeAdapter) { + return runtime.transport.sendMessage(options, { + expectFinal: false, + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + }); + } + return runtime.sendMessage(options); + }; +} + +export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetworkAPI, ContactListingNetworkAPI, UserSearchingNetworkAPI, MessageHandlingNetworkAPI, EditHandlingNetworkAPI, ReactionHandlingNetworkAPI, ReactionRemoveHandlingNetworkAPI, RedactionHandlingNetworkAPI, ReadReceiptHandlingNetworkAPI, MarkedUnreadHandlingNetworkAPI, TypingHandlingNetworkAPI, RoomNameHandlingNetworkAPI, RoomTopicHandlingNetworkAPI, RoomAvatarHandlingNetworkAPI, MembershipHandlingNetworkAPI, DeleteChatHandlingNetworkAPI { + readonly #agent: OpenClawMatrixBridgeAgent; + readonly #config: OpenClawBridgeConfig; + readonly #login: UserLogin; + readonly #onActivity: ((patch: OpenClawBridgeActivityPatch) => void) | undefined; + readonly #registry: OpenClawBridgeRegistry; + readonly #runtime: OpenClawBridgeRuntime; + #welcomeRooms: Promise | undefined; + + constructor(options: { + config: OpenClawBridgeConfig; + login: UserLogin; + onActivity?: (patch: OpenClawBridgeActivityPatch) => void; + registry: OpenClawBridgeRegistry; + runtime: OpenClawBridgeRuntime; + sendTurn?: (options: OpenClawSessionSendOptions) => Promise; + }) { + this.#config = options.config; + this.#login = options.login; + this.#onActivity = options.onActivity; + this.#registry = options.registry; + this.#runtime = options.runtime; + this.#agent = new OpenClawMatrixBridgeAgent({ + registry: options.registry, + runtime: options.runtime, + ...(options.sendTurn ? { sendTurn: options.sendTurn } : {}), + }); + } + + async connect(ctx: ConnectContext): Promise { + await this.#agent.syncAgentContacts(); + this.ensureDefaultAgentContact(); + for (const contact of this.#registry.data.agents) { + await ctx.bridge.registerGhost(agentGhost(contact)); + } + this.syncAgentBindings(); + await this.#registry.save(); + await this.ensureAgentWelcomeRooms(ctx); + } + + async disconnect(): Promise { + await this.#runtime.close(); + } + + async resolveIdentifier(ctx: BridgeRequestContext, params: ResolveIdentifierParams): Promise { + await this.#agent.syncAgentContacts(); + const contact = findAgentContact(this.#registry.data.agents, params.identifier); + if (!contact) return {}; + let portal = params.createDM + ? await this.createSessionPortalForAgent(ctx, contact) + : undefined; + if (portal && params.createDM && !portal.mxid) { + const portalOptions: Parameters[1] = { + id: portal.id, + ...agentPortalInfo(contact), + metadata: portal.metadata, + roomType: "dm", + sender: contact.ghostUserId, + }; + const creationContent = openClawPortalCreationContent(this.#runtime.config); + if (creationContent) portalOptions.creationContent = creationContent; + const created = await ctx.bridge.createPortal(this.#login, portalOptions); + const nextPortal: Portal = { + ...portal, + ...created, + metadata: created.metadata ?? portal.metadata, + portalKey: created.portalKey ?? portal.portalKey, + }; + const receiver = created.receiver ?? portal.receiver; + if (receiver !== undefined) nextPortal.receiver = receiver; + portal = nextPortal; + this.upsertPortalBinding(portal); + await this.#registry.save(); + } + return contactResponse(contact, portal); + } + + async listContacts(_ctx: BridgeRequestContext, params: ListContactsParams = {}): Promise { + return { contacts: await this.#agentContactResponses(params.query, params.limit) }; + } + + async searchUsers(_ctx: BridgeRequestContext, params: SearchUsersParams): Promise { + return { results: await this.#agentContactResponses(params.query) }; + } + + async #agentContactResponses(query?: string, limit?: number): Promise { + await this.#agent.syncAgentContacts(); + const normalizedQuery = query?.trim().toLowerCase(); + return this.#registry.data.agents + .map((contact) => ({ + response: contactResponse(contact), + text: `${contact.agentId} ${contact.displayName}`.toLowerCase(), + })) + .filter((contact) => !normalizedQuery || contact.text.includes(normalizedQuery)) + .slice(0, limit ?? 100) + .map((contact) => contact.response); + } + + async handleMatrixMessage(ctx: BridgeRequestContext, msg: MatrixMessage): Promise { + if (!this.isAllowedMatrixIngress(msg.portal.mxid, msg.sender.userId)) { + this.logRejectedMatrixIngress(ctx, "message", msg.portal.mxid, msg.sender.userId); + return { pending: false }; + } + this.#onActivity?.(inboundActivityPatch()); + const binding = bindingFromPortal(msg.portal, this.#runtime.config); + if (binding && !this.#registry.getBindingByRoom(msg.portal.mxid ?? "")) this.#registry.upsertBinding(binding); + let currentBinding = msg.portal.mxid ? this.#registry.getBindingByRoom(msg.portal.mxid) ?? binding : binding; + const approval = parseApprovalResponseContent(msg.content); + if (approval) { + await this.#agent.handleApprovalContent(msg.content, approval.approvalId ?? approvalIdFromMatrixReply(msg)); + return { pending: false }; + } + const parsed = parseMatrixTextMessage(msg.text, msg.content, msg); + if (msg.portal.mxid) { + if (currentBinding) this.registerCanonicalPortalForBinding(ctx, msg.portal, currentBinding); + if (!currentBinding) { + ctx.log?.("warn", "openclaw_matrix_message_unbound_room", { + portalId: msg.portal.id, + portalKey: msg.portal.portalKey, + roomId: msg.portal.mxid, + }); + currentBinding = await this.createBindingForMatrixRoom(msg.portal.mxid, DEFAULT_NEW_SESSION_LABEL); + ctx.log?.("info", "openclaw_matrix_message_bound_room", { + agentId: currentBinding.agentId, + roomId: msg.portal.mxid, + }); + } + this.registerCanonicalPortalForBinding(ctx, msg.portal, currentBinding); + ctx.log?.("info", "openclaw_matrix_message_dispatching", { + eventId: msg.event.eventId, + roomId: msg.portal.mxid, + ...(currentBinding.sessionKey ? { sessionKey: currentBinding.sessionKey } : {}), + }); + await this.#agent.handleMatrixText({ + ...(parsed.attachments.length > 0 ? { attachments: parsed.attachments } : {}), + eventId: msg.event.eventId, + matrix: matrixMetadataFromParsed(this.#config, parsed, msg.sender.userId, streamTargetRelationPatch(currentBinding, parsed.replyToEventId)), + roomId: msg.portal.mxid, + ...(parsed.replyToEventId ? { replyToEventId: parsed.replyToEventId } : {}), + sender: msg.sender.userId, + text: parsed.text, + }); + const updatedBinding = this.#registry.getBindingByRoom(msg.portal.mxid); + if (updatedBinding) this.registerCanonicalPortalForBinding(ctx, msg.portal, updatedBinding); + ctx.log?.("info", "openclaw_matrix_message_dispatched", { + eventId: msg.event.eventId, + lastRunId: updatedBinding?.lastRunId, + roomId: msg.portal.mxid, + sessionKey: updatedBinding?.sessionKey, + }); + } + return { pending: false }; + } + + async handleMatrixEdit(_ctx: BridgeRequestContext, msg: MatrixEdit): Promise { + if (!this.isAllowedMatrixIngress(msg.portal.mxid, msg.sender.userId)) return { pending: false }; + this.upsertPortalBinding(msg.portal); + const parsed = parseMatrixTextMessage(msg.text, msg.content, msg); + const targetId = msg.targetMessage.id; + const binding = msg.portal.mxid ? this.#registry.getBindingByRoom(msg.portal.mxid) : undefined; + if (msg.portal.mxid) { + await this.#agent.handleMatrixText({ + ...(parsed.attachments.length > 0 ? { attachments: parsed.attachments } : {}), + eventId: `${msg.event.eventId}:edit`, + matrix: matrixMetadataFromParsed(this.#config, parsed, msg.sender.userId, { + kind: "edit", + targetEventId: targetId, + ...streamTargetRelationPatch(binding, targetId), + }), + roomId: msg.portal.mxid, + replyToEventId: targetId, + sender: msg.sender.userId, + text: parsed.text, + }); + } + return { pending: false }; + } + + async handleMatrixReaction(_ctx: BridgeRequestContext, msg: MatrixReaction): Promise { + if (!this.isAllowedMatrixIngress(msg.portal.mxid, senderUserId(msg.event.sender))) return null; + const approval = parseApprovalResponseContent(msg.content); + if (approval) { + if (!approvalReactionsEnabled(this.#runtime.config)) { + return { id: msg.event.eventId, metadata: { openclaw: { approval, ignored: "approval-reactions-disabled" } } }; + } + await this.#agent.handleApprovalContent(msg.content, approval.approvalId ?? msg.targetMessage.id); + return { id: msg.event.eventId, metadata: { openclaw: { approval } } }; + } + const approvalReaction = parseApprovalReactionContent(msg.content); + if (approvalReaction) { + return { id: msg.event.eventId, metadata: { openclaw: { approval: approvalReaction, ignored: "approval-reactions-disabled" } } }; + } + const reactionKey = matrixReactionKey(msg.content); + if (!reactionKey || !msg.portal.mxid) return null; + this.upsertPortalBinding(msg.portal); + const binding = this.#registry.getBindingByRoom(msg.portal.mxid); + await this.#agent.handleMatrixText({ + eventId: msg.event.eventId, + matrix: { + relation: { + key: reactionKey, + kind: "reaction", + targetEventId: msg.targetMessage.id, + ...streamTargetRelationPatch(binding, msg.targetMessage.id), + }, + sender: senderUserId(msg.event.sender) ?? "reaction", + }, + roomId: msg.portal.mxid, + replyToEventId: msg.targetMessage.id, + sender: senderUserId(msg.event.sender) ?? "reaction", + text: `Reacted ${reactionKey} to ${msg.targetMessage.id}`, + }); + return { id: msg.event.eventId, metadata: { openclaw: { reaction: reactionKey, targetMessageId: msg.targetMessage.id } } }; + } + + async handleMatrixReactionRemove(_ctx: BridgeRequestContext, msg: MatrixReactionRemove): Promise { + if (!this.isAllowedMatrixIngress(msg.portal.mxid, senderUserId(msg.event.sender))) return; + const reactionKey = matrixReactionKey(msg.content); + if (!msg.portal.mxid) return; + this.upsertPortalBinding(msg.portal); + const binding = this.#registry.getBindingByRoom(msg.portal.mxid); + await this.#agent.handleMatrixText({ + eventId: msg.event.eventId, + matrix: { + relation: { + ...(reactionKey ? { key: reactionKey } : {}), + kind: "reaction_remove", + targetEventId: msg.targetMessage.id, + ...(msg.targetReaction.id ? { targetReactionId: msg.targetReaction.id } : {}), + ...streamTargetRelationPatch(binding, msg.targetMessage.id), + }, + sender: senderUserId(msg.event.sender) ?? "reaction", + }, + roomId: msg.portal.mxid, + replyToEventId: msg.targetMessage.id, + sender: senderUserId(msg.event.sender) ?? "reaction", + text: reactionKey + ? `Removed reaction ${reactionKey} from ${msg.targetMessage.id}` + : `Removed reaction from ${msg.targetMessage.id}`, + }); + } + + async handleMatrixRedaction(_ctx: BridgeRequestContext, msg: MatrixRedaction): Promise { + if (!msg.portal.mxid) return; + if (!this.isAllowedRoom(msg.portal.mxid)) return; + this.upsertPortalBinding(msg.portal); + const binding = this.#registry.getBindingByRoom(msg.portal.mxid); + await this.#agent.handleMatrixText({ + eventId: msg.eventId, + matrix: { + relation: { + kind: "redaction", + ...(msg.targetMessage?.id ? { targetEventId: msg.targetMessage.id } : {}), + ...streamTargetRelationPatch(binding, msg.targetMessage?.id), + }, + sender: "redaction", + }, + roomId: msg.portal.mxid, + ...(msg.targetMessage?.id ? { replyToEventId: msg.targetMessage.id } : {}), + sender: "redaction", + text: msg.targetMessage?.id ? `Redacted message ${msg.targetMessage.id}` : "Redacted a Matrix event", + }); + } + + async handleMatrixReadReceipt(_ctx: BridgeRequestContext, msg: MatrixReadReceipt): Promise { + if (!msg.portal.mxid) return; + if (!this.isAllowedRoom(msg.portal.mxid)) return; + this.upsertPortalBinding(msg.portal); + } + + async handleMatrixMarkedUnread(_ctx: BridgeRequestContext, msg: MatrixMarkedUnread): Promise { + if (!msg.portal.mxid) return; + if (!this.isAllowedRoom(msg.portal.mxid)) return; + this.upsertPortalBinding(msg.portal); + } + + async handleMatrixTyping(_ctx: BridgeRequestContext, msg: MatrixTyping): Promise { + if (!msg.portal.mxid) return; + if (!this.isAllowedMatrixIngress(msg.portal.mxid, msg.userId)) return; + this.upsertPortalBinding(msg.portal); + } + + async handleMatrixRoomName(_ctx: BridgeRequestContext, msg: MatrixRoomName): Promise { + const roomId = msg.portal.mxid; + const binding = roomId ? this.#registry.getBindingByRoom(roomId) ?? bindingFromPortal(msg.portal, this.#runtime.config) : undefined; + if (!roomId || !binding || !msg.name) return false; + this.#registry.upsertBinding({ ...binding, label: msg.name, updatedAt: Date.now() }); + await this.#registry.save(); + return true; + } + + async handleMatrixRoomTopic(_ctx: BridgeRequestContext, msg: MatrixRoomTopic): Promise { + if (!msg.portal.mxid || !this.isAllowedRoom(msg.portal.mxid)) return false; + this.upsertPortalBinding(msg.portal); + return true; + } + + async handleMatrixRoomAvatar(_ctx: BridgeRequestContext, msg: MatrixRoomAvatar): Promise { + if (!msg.portal.mxid || !this.isAllowedRoom(msg.portal.mxid)) return false; + this.upsertPortalBinding(msg.portal); + return true; + } + + async handleMatrixMembership(_ctx: BridgeRequestContext, msg: MatrixMembership): Promise { + if (!msg.portal.mxid || !this.isAllowedRoom(msg.portal.mxid)) return; + this.upsertPortalBinding(msg.portal); + } + + async handleMatrixDeleteChat(_ctx: BridgeRequestContext, msg: MatrixDeleteChat): Promise { + if (!msg.portal.mxid || !this.isAllowedRoom(msg.portal.mxid)) return; + this.#registry.removeBindingByRoom(msg.portal.mxid); + await this.#registry.save(); + } + + isAllowedMatrixIngress(roomId: string | undefined, sender: string | undefined): boolean { + if (!roomId) return false; + if (sender && this.isBridgeOwnedSender(sender)) return false; + return true; + } + + isAllowedRoom(roomId: string | undefined): boolean { + return Boolean(roomId); + } + + isAllowedUser(sender: string | undefined): boolean { + return Boolean(sender); + } + + isBridgeOwnedSender(sender: string): boolean { + return sender === serviceBotUserId(this.#config) + || this.#registry.data.agents.some((contact) => contact.ghostUserId === sender); + } + + logRejectedMatrixIngress(ctx: BridgeRequestContext, kind: string, roomId: string | undefined, sender: string | undefined): void { + ctx.log?.("warn", "openclaw_matrix_ingress_rejected", { + bridgeOwned: sender ? this.isBridgeOwnedSender(sender) : false, + kind, + roomId, + sender, + }); + } + + private upsertPortalBinding(portal: Portal): void { + const binding = bindingFromPortal(portal, this.#runtime.config); + if (binding && !this.#registry.getBindingByRoom(portal.mxid ?? "")) this.#registry.upsertBinding(binding); + } + + private registerCanonicalPortalForBinding( + ctx: BridgeRequestContext, + portal: Portal, + binding: OpenClawSessionBinding, + ): Portal { + const canonical = canonicalPortalForBinding(portal, binding, this.#login.id); + ctx.bridge?.registerPortal?.(canonical); + return canonical; + } + + private resolveNewSessionCommand( + args: string, + binding: OpenClawSessionBinding | undefined, + ): { agentId: string; ghostUserId: string; label: string } | undefined { + const trimmed = args.trim(); + if (binding) { + return { + agentId: binding.agentId, + ghostUserId: binding.ghostUserId, + label: trimmed || DEFAULT_NEW_SESSION_LABEL, + }; + } + const [agentId, ...labelParts] = trimmed.split(/\s+/u).filter(Boolean); + const contact = agentId + ? this.#registry.getAgent(agentId) ?? agentContactFromOpenClawAgent(this.#runtime.config, { id: agentId }) + : this.#registry.getAgent("main") ?? agentContactFromOpenClawAgent(this.#runtime.config, { id: "main" }); + return { + agentId: contact.agentId, + ghostUserId: contact.ghostUserId, + label: labelParts.join(" ") || DEFAULT_NEW_SESSION_LABEL, + }; + } + + private async createBindingForMatrixRoom( + roomId: string, + label: string, + agentId = "main", + ghostUserId = (this.#registry.getAgent(agentId) ?? agentContactFromOpenClawAgent(this.#runtime.config, { id: agentId })).ghostUserId, + ): Promise { + const existing = this.#registry.getBindingByRoom(roomId); + if (existing) return existing; + const now = Date.now(); + const binding: OpenClawSessionBinding = { + agentId, + createdAt: now, + ghostUserId, + id: Buffer.from(roomId).toString("base64url"), + label, + roomId, + updatedAt: now, + }; + this.#registry.upsertBinding(binding); + await this.#registry.save(); + return binding; + } + + private async createSessionPortalForAgent( + _ctx: BridgeRequestContext, + contact: OpenClawAgentContact, + label = contact.displayName, + ): Promise { + return portalForAgentConversation(contact, this.#login.id, label); + } + + private ensureDefaultAgentContact(): void { + const contact = + this.#registry.getAgent("main") ?? + this.#registry.data.agents[0] ?? + agentContactFromOpenClawAgent(this.#runtime.config, { id: "main" }); + if (!this.#registry.getAgent(contact.agentId)) this.#registry.upsertAgent(contact); + } + + private syncAgentBindings(): void { + for (const contact of this.#registry.data.agents) { + for (const binding of this.#registry.getBindingsByAgent(contact.agentId)) { + this.#registry.updateBinding(binding.id, (current) => stripUndefined({ + ...current, + ghostUserId: contact.ghostUserId, + ...(!current.sessionKey ? { label: contact.displayName } : {}), + updatedAt: Date.now(), + })); + } + } + } + + private async ensureAgentWelcomeRooms(ctx: ConnectContext): Promise { + if (this.#welcomeRooms) return this.#welcomeRooms; + this.#welcomeRooms = this.createMissingAgentWelcomeRooms(ctx); + try { + await this.#welcomeRooms; + } finally { + this.#welcomeRooms = undefined; + } + } + + private async createMissingAgentWelcomeRooms(ctx: ConnectContext): Promise { + for (const contact of this.#registry.data.agents) { + if (this.#registry.getBindingById(agentPortalBindingId(contact.agentId))) continue; + await this.createAgentWelcomeRoom(ctx, contact); + } + } + + private async createAgentWelcomeRoom(ctx: ConnectContext, contact: OpenClawAgentContact): Promise { + let portal = portalForAgentWelcome(contact, this.#login.id); + const portalOptions: Parameters[1] = { + id: portal.id, + ...agentPortalInfo(contact), + metadata: portal.metadata, + roomType: "dm", + sender: contact.ghostUserId, + }; + const creationContent = openClawPortalCreationContent(this.#runtime.config); + if (creationContent) portalOptions.creationContent = creationContent; + const created = await ctx.bridge.createPortal(this.#login, portalOptions); + portal = { + ...portal, + ...created, + metadata: created.metadata ?? portal.metadata, + portalKey: created.portalKey ?? portal.portalKey, + }; + const receiver = created.receiver ?? portal.receiver; + if (receiver !== undefined) portal.receiver = receiver; + this.upsertPortalBinding(portal); + const binding = portal.mxid ? this.#registry.getBindingByRoom(portal.mxid) : undefined; + if (!portal.mxid || !binding) throw new Error("OpenClaw Beeper agent welcome room was created without a bound Matrix room"); + this.registerCanonicalPortalForBinding(ctx, portal, binding); + await this.#registry.save(); + } + +} + +function inboundActivityPatch(now = Date.now()): OpenClawBridgeActivityPatch { + return { + lastEventAt: now, + lastInboundAt: now, + lastTransportActivityAt: now, + }; +} + +function canonicalPortalForBinding(portal: Portal, binding: OpenClawSessionBinding, receiver: string): Portal { + const id = portal.portalKey.id || portal.id; + return { + ...portal, + id, + metadata: { + ...(recordValue(portal.metadata) ?? {}), + openclaw: stripUndefined({ + ...(recordValue(recordValue(portal.metadata)?.openclaw) ?? {}), + agentId: binding.agentId, + ghostUserId: binding.ghostUserId, + ...(binding.label ? { label: binding.label } : {}), + ...(binding.sessionKey ? { sessionKey: binding.sessionKey } : {}), + }), + }, + mxid: binding.roomId, + portalKey: { id, receiver }, + receiver, + roomType: portal.roomType ?? "dm", + }; +} + +function approvalReactionsEnabled(_config: OpenClawBridgeConfig): boolean { + return false; +} + +function openClawPortalCreationContent(_config: OpenClawBridgeConfig): Record | undefined { + return { "m.federate": false }; +} + +function streamTargetRelationPatch( + binding: OpenClawSessionBinding | undefined, + targetEventId: string | undefined, +): Partial> { + if (!binding?.lastStreamTargetEventId || binding.lastStreamTargetEventId !== targetEventId) return {}; + const patch: Partial> = { + ...(binding.sessionKey ? { targetSessionKey: binding.sessionKey } : {}), + }; + const targetRunId = binding.lastStreamRunId ?? binding.lastRunId; + if (targetRunId) patch.targetRunId = targetRunId; + return patch; +} + +function matrixMetadataFromParsed( + config: OpenClawBridgeConfig, + parsed: ParsedMatrixTextMessage, + sender: string, + relationPatch: NonNullable = {}, +): OpenClawMatrixMessageMetadata { + const metadata: OpenClawMatrixMessageMetadata = { sender }; + const accountId = beeperAccountIdFromMatrixUserId(config.matrixUserId); + if (accountId) metadata.accountId = accountId; + if (parsed.attachments.length > 0) metadata.attachments = parsed.attachments as NonNullable; + if (parsed.command) metadata.command = parsed.command; + if (parsed.formattedBody) metadata.formattedBody = parsed.formattedBody; + if (parsed.mentions) metadata.mentions = parsed.mentions; + if (parsed.threadRootEventId) metadata.threadRootEventId = parsed.threadRootEventId; + if (parsed.replyToEventId || parsed.threadRootEventId || parsed.replyQuote || Object.keys(relationPatch).length > 0) { + metadata.relation = { + kind: parsed.threadRootEventId ? "thread" : "reply", + ...(parsed.replyToEventId ? { replyToEventId: parsed.replyToEventId } : {}), + ...(parsed.threadRootEventId ? { threadRootEventId: parsed.threadRootEventId } : {}), + ...(parsed.replyQuote ? { quote: parsed.replyQuote } : {}), + ...relationPatch, + }; + } + return metadata; +} + +function portalForAgentWelcome(contact: OpenClawAgentContact, receiver: string): Portal { + const id = agentPortalBindingId(contact.agentId); + return { + id, + metadata: { + openclaw: stripUndefined({ + agentId: contact.agentId, + ghostUserId: contact.ghostUserId, + label: contact.displayName, + }), + }, + portalKey: { id, receiver }, + receiver, + roomType: "dm", + }; +} + +function agentPortalBindingId(agentId: string): string { + return `agent:${agentId}`; +} + +function portalForAgentConversation(contact: OpenClawAgentContact, receiver: string, label?: string): Portal { + const id = `conversation:${Buffer.from(randomUUID()).toString("base64url")}`; + return { + id, + metadata: { + openclaw: stripUndefined({ + agentId: contact.agentId, + ghostUserId: contact.ghostUserId, + ...(label ? { label } : {}), + }), + }, + portalKey: { id, receiver }, + receiver, + roomType: "dm", + }; +} + +function findAgentContact(contacts: readonly OpenClawAgentContact[], identifier: string): OpenClawAgentContact | undefined { + const normalized = identifier.trim().toLowerCase(); + if (!normalized) return undefined; + return contacts.find((contact) => + contact.agentId.toLowerCase() === normalized || + contact.ghostUserId.toLowerCase() === normalized || + contact.displayName.toLowerCase() === normalized + ); +} + +function contactResponse(contact: OpenClawAgentContact, portal?: Portal): ResolveIdentifierResponse { + return { + ghost: agentGhost(contact), + ...(portal ? { portal } : {}), + userId: contact.ghostUserId, + }; +} + +function agentGhost(contact: OpenClawAgentContact) { + const avatar = agentAvatar(contact); + return { + ...(avatar ? { avatar } : {}), + displayName: contact.displayName, + id: contact.agentId, + identifiers: [`openclaw:agent:${contact.agentId}`, contact.ghostUserId], + isBot: true, + metadata: { openclaw: contact }, + mxid: contact.ghostUserId, + profile: { "com.beeper.openclaw.agent": contact }, + }; +} + +function agentPortalInfo(contact: OpenClawAgentContact): { avatarUrl?: string; name: string; topic?: string } { + const info: { avatarUrl?: string; name: string; topic?: string } = { name: contact.displayName }; + const avatarUrl = contact.avatarMxc ?? contact.avatarUrl; + if (avatarUrl) info.avatarUrl = avatarUrl; + if (contact.description) info.topic = contact.description; + return info; +} + +function agentAvatar(contact: OpenClawAgentContact): Avatar | undefined { + const url = contact.avatarUrl ?? contact.avatarMxc; + const id = contact.avatarMxc ?? url; + if (!id) return undefined; + return { + id, + ...(contact.avatarMxc ? { mxc: contact.avatarMxc } : {}), + ...(url ? { url } : {}), + }; +} + +function bindingFromPortal(portal: Portal, config: OpenClawBridgeConfig): OpenClawSessionBinding | undefined { + const metadata = recordValue(portal.metadata)?.openclaw; + const openclaw = recordValue(metadata); + if (!openclaw) return undefined; + const roomId = portal.mxid; + const portalId = portal.portalKey.id || portal.id; + const sessionKey = stringValue(openclaw.sessionKey); + const agentId = stringValue(openclaw.agentId); + const ghostUserId = stringValue(openclaw.ghostUserId) ?? (agentId ? agentGhostUserId(config, agentId) : undefined); + if (!roomId || !agentId || !ghostUserId) return undefined; + const now = Date.now(); + const label = stringValue(openclaw.label); + return { + agentId, + createdAt: now, + ghostUserId, + id: portalId.startsWith("agent:") ? portalId : Buffer.from(roomId).toString("base64url"), + ...(label ? { label } : {}), + roomId, + ...(sessionKey ? { sessionKey } : {}), + updatedAt: now, + }; +} + +export function userLoginFromOpenClawConfig(config: OpenClawBridgeConfig): UserLogin { + return { + id: "openclaw:plugin", + metadata: {}, + remoteName: "OpenClaw", + userId: config.matrixUserId ?? serviceBotUserId(config, config.homeserverDomain ?? matrixDomainFromHomeserver(config.homeserver)), + }; +} + +function registerBeeperRuntimeContext(hostRuntime: OpenClawHostRuntime | undefined, runtime: BeeperChannelRuntime, config: OpenClawBridgeConfig): void { + const channel = recordValue(hostRuntime)?.channel; + const runtimeContexts = recordValue(channel)?.runtimeContexts; + const register = recordValue(runtimeContexts)?.register; + if (typeof register !== "function") return; + const accountId = beeperAccountIdFromMatrixUserId(config.matrixUserId); + if (!accountId) return; + register.call(runtimeContexts, { + accountId, + capability: BEEPER_CHANNEL_RUNTIME_CONTEXT_CAPABILITY, + channelId: "beeper", + context: runtime, + }); +} + +function recordValue(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function matrixReactionKey(content: unknown): string | undefined { + const relates = recordValue(recordValue(content)?.["m.relates_to"]); + return stringValue(relates?.key); +} + +function approvalIdFromMatrixReply(msg: MatrixMessage): string | undefined { + const content = recordValue(msg.content); + const relates = recordValue(content?.["m.relates_to"]); + const inReplyTo = recordValue(relates?.["m.in_reply_to"]); + return stringValue(msg.replyTo?.id) + ?? stringValue(msg.event.replyTo) + ?? stringValue(content?.approvalId) + ?? stringValue(inReplyTo?.event_id) + ?? stringValue(relates?.event_id); +} + +function senderUserId(sender: unknown): string | undefined { + if (typeof sender === "string") return sender; + return stringValue(recordValue(sender)?.userId); +} + +export { parseMatrixTextMessage, type ParsedMatrixTextMessage } from "./matrix-parser"; + +function stripUndefined>(input: T): T { + for (const key of Object.keys(input)) { + if (input[key] === undefined) delete input[key]; + } + return input; +} diff --git a/packages/openclaw/src/ids.ts b/packages/openclaw/src/ids.ts new file mode 100644 index 0000000..59ebaa3 --- /dev/null +++ b/packages/openclaw/src/ids.ts @@ -0,0 +1,9 @@ +export const DEFAULT_BEEPER_BRIDGE_TYPE = "openclaw"; +const BEEPER_BRIDGE_PREFIX = "sh-openclaw-"; +const BEEPER_BRIDGE_MAX_LENGTH = 32; + +export function openClawBeeperBridgeId(deviceId: string): string { + const normalized = deviceId.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + if (!normalized) throw new Error("Cannot build Beeper bridge id without a device id"); + return `${BEEPER_BRIDGE_PREFIX}${normalized.slice(0, BEEPER_BRIDGE_MAX_LENGTH - BEEPER_BRIDGE_PREFIX.length)}`; +} diff --git a/packages/openclaw/src/integration.test.ts b/packages/openclaw/src/integration.test.ts new file mode 100644 index 0000000..1026581 --- /dev/null +++ b/packages/openclaw/src/integration.test.ts @@ -0,0 +1,597 @@ +import { RuntimeBridge } from "@beeper/pickle-bridge/bridge"; +import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscription } from "@beeper/pickle-bridge/types"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createDefaultConfig } from "./config"; +import { createOpenClawConnector, userLoginFromOpenClawConfig } from "./connector"; +import { OpenClawPluginRuntimeAdapter, type OpenClawGatewayEvent, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; +import { OpenClawBridgeRegistry } from "./registry"; + +describe("OpenClaw bridge integration", () => { + it("dispatches a Matrix DM through Pickle into OpenClaw", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-integration-")); + const config = createDefaultConfig({ + dataDir: dir, + homeserver: "https://matrix.example", + matrixUserId: "@sh-openclawbot:example", + }); + const transport = fakeTransport({ + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + "sessions.create": { key: "session_1" }, + "beeper.turn": { runId: "run_1", sessionKey: "session_1" }, + }, + }); + const registry = new OpenClawBridgeRegistry(resolve(dir, "registry.json")); + const runtime = new OpenClawPluginRuntimeAdapter({ config, transport }); + runtime.sendMessage = vi.fn(async (params) => ({ raw: transport.responses["beeper.turn"], runId: "run_1", sessionKey: params.sessionKey === "session_1" ? "session_1" : "session_1" })); + const connector = createOpenClawConnector({ + config, + registry, + runtimeFactory: () => runtime, + }); + const client = createFakeMatrixClient(); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login = userLoginFromOpenClawConfig(config); + + await bridge.start(); + await bridge.loadUserLogin(login); + bridge.registerPortal({ + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@sh-openclaw_agent_codex:matrix.example", + }, + }, + mxid: "!codex:example", + portalKey: { id: "agent:codex", receiver: login.id }, + receiver: login.id, + }); + + await expect(bridge.dispatchMatrixEvent(messageEvent({ + body: "hello", + eventId: "$hello", + roomId: "!codex:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ + dispatched: true, + handlers: 1, + roomId: "!codex:example", + }); + + expect(transport.request).toHaveBeenCalledWith("sessions.create", { + agentId: "codex", + }); + expect(runtime.sendMessage).toHaveBeenCalledWith({ + idempotencyKey: "$hello", + matrix: { accountId: "@sh-openclawbot:example", roomId: "!codex:example", sender: "@alice:example" }, + message: "hello", + sessionKey: "session_1", + }); + expect(registry.getBindingByRoom("!codex:example")).toMatchObject({ + lastMatrixEventId: "$hello", + lastRunId: "run_1", + sessionKey: "session_1", + }); + }); + + it("ignores approval reactions instead of using fallback approval resolution", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-approval-integration-")); + const config = createDefaultConfig({ + dataDir: dir, + homeserver: "https://matrix.example", + matrixUserId: "@sh-openclawbot:example", + }); + const transport = fakeTransport({ + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + "exec.approval.resolve": { ok: true }, + }, + }); + const registry = new OpenClawBridgeRegistry(resolve(dir, "registry.json")); + const runtime = new OpenClawPluginRuntimeAdapter({ config, transport }); + runtime.sendMessage = vi.fn(async (params) => ({ raw: transport.responses["beeper.turn"], runId: "run_relation", sessionKey: params.sessionKey })); + const connector = createOpenClawConnector({ + config, + registry, + runtimeFactory: () => runtime, + }); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, createFakeMatrixClient()); + const login = userLoginFromOpenClawConfig(config); + + await bridge.start(); + await bridge.loadUserLogin(login); + bridge.registerPortal({ + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@sh-openclaw_agent_codex:matrix.example", + }, + }, + mxid: "!codex:example", + portalKey: { id: "agent:codex", receiver: login.id }, + receiver: login.id, + }); + + await expect(bridge.dispatchMatrixEvent(reactionEvent({ + eventId: "$approve-reaction", + key: "approval.allow_once", + relatesTo: "approval_1", + roomId: "!codex:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ + dispatched: true, + handlers: 1, + kind: "reaction", + roomId: "!codex:example", + }); + + expect(transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1", + decision: "approve", + }); + }); + + it("dispatches Matrix edits, emoji reactions, and redactions while ignoring receipt-only state as agent turns", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-relations-integration-")); + const config = createDefaultConfig({ + dataDir: dir, + homeserver: "https://matrix.example", + matrixUserId: "@sh-openclawbot:example", + }); + const transport = fakeTransport({ + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + "beeper.turn": { runId: "run_relation", sessionKey: "agent:codex:session_1" }, + }, + }); + const registry = new OpenClawBridgeRegistry(resolve(dir, "registry.json")); + const runtime = new OpenClawPluginRuntimeAdapter({ config, transport }); + runtime.sendMessage = vi.fn(async (params) => ({ raw: transport.responses["beeper.turn"], runId: "run_relation", sessionKey: params.sessionKey })); + const connector = createOpenClawConnector({ + config, + registry, + runtimeFactory: () => runtime, + }); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, createFakeMatrixClient()); + const login = userLoginFromOpenClawConfig(config); + + await bridge.start(); + await bridge.loadUserLogin(login); + bridge.registerPortal({ + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@sh-openclaw_agent_codex:matrix.example", + sessionKey: "agent:codex:session_1", + }, + }, + mxid: "!codex:example", + portalKey: { id: "agent:codex", receiver: login.id }, + receiver: login.id, + }); + + await expect(bridge.dispatchMatrixEvent(editEvent({ + body: "corrected", + eventId: "$edit", + replaces: "$old", + roomId: "!codex:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ dispatched: true, handlers: 1, roomId: "!codex:example" }); + await expect(bridge.dispatchMatrixEvent(reactionEvent({ + eventId: "$react", + key: "👍", + relatesTo: "$old", + roomId: "!codex:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ dispatched: true, handlers: 1, kind: "reaction" }); + await expect(bridge.dispatchMatrixEvent(redactionEvent({ + eventId: "$redact", + redacts: "$old", + roomId: "!codex:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ dispatched: true, handlers: 1, kind: "redaction" }); + await expect(bridge.dispatchMatrixEvent(receiptEvent({ + eventId: "$old", + roomId: "!codex:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ dispatched: true, handlers: 1, kind: "receipt" }); + await expect(bridge.dispatchMatrixEvent(markedUnreadEvent({ + roomId: "!codex:example", + sender: "@alice:example", + unread: true, + }))).resolves.toMatchObject({ dispatched: true, handlers: 1, kind: "accountData" }); + + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$edit:edit", + matrix: expect.objectContaining({ + relation: expect.objectContaining({ kind: "edit", targetEventId: "$old" }), + }), + message: "corrected", + replyTo: { eventId: "$old", roomId: "!codex:example" }, + })); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$react", + matrix: expect.objectContaining({ + relation: expect.objectContaining({ key: "👍", kind: "reaction", targetEventId: "$old" }), + }), + message: "Reacted 👍 to $old", + replyTo: { eventId: "$old", roomId: "!codex:example" }, + })); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$redact", + matrix: expect.objectContaining({ + relation: expect.objectContaining({ kind: "redaction", targetEventId: "$old" }), + }), + message: "Redacted message $old", + replyTo: { eventId: "$old", roomId: "!codex:example" }, + })); + const sessionSendPayloads = runtime.sendMessage.mock.calls.map(([payload]) => payload); + expect(sessionSendPayloads).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ message: "Read receipt for $old" }), + expect.objectContaining({ message: "Marked room unread" }), + ])); + }); + + it("smokes contact DM creation, Matrix ingress, and approval with local fakes", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-local-smoke-")); + const config = createDefaultConfig({ + asToken: "as-token", + dataDir: dir, + homeserver: "https://matrix.example", + hsToken: "hs-token", + matrixDeviceId: "DEVICE", + matrixUserId: "@sh-openclawbot:example", + }); + const transport = fakeTransport({ + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + "exec.approval.resolve": { ok: true }, + "sessions.create": { key: "session_1" }, + "beeper.turn": { runId: "run_1", sessionKey: "session_1" }, + }, + }); + const registry = new OpenClawBridgeRegistry(resolve(dir, "registry.json")); + const client = createFakeMatrixClient(); + const runtime = new OpenClawPluginRuntimeAdapter({ config, transport }); + runtime.sendMessage = vi.fn(async (params) => ({ raw: transport.responses["beeper.turn"], runId: "run_1", sessionKey: params.sessionKey })); + const connector = createOpenClawConnector({ + config, + registry, + runtimeFactory: () => runtime, + }); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login = userLoginFromOpenClawConfig(config); + + await bridge.start(); + await bridge.loadUserLogin(login); + + await expect(bridge.resolveIdentifier(login, { + createDM: false, + identifier: "codex", + type: "username", + })).resolves.toMatchObject({ + ghost: { + displayName: "Codex", + mxid: "@sh-openclaw_agent_codex:matrix.example", + }, + }); + + const resolved = await bridge.resolveIdentifier(login, { + createDM: true, + identifier: "codex", + type: "username", + }); + expect(resolved.portal).toMatchObject({ + id: expect.stringMatching(/^conversation:/), + mxid: "!created:example", + portalKey: { id: expect.stringMatching(/^conversation:/), receiver: login.id }, + }); + expect(client.appservice.createPortalRoom).toHaveBeenLastCalledWith(expect.objectContaining({ + creationContent: { "m.federate": false }, + isDirect: true, + name: "Codex", + portalKey: { id: expect.stringMatching(/^conversation:/), receiver: login.id }, + roomType: "dm", + })); + + await expect(bridge.dispatchMatrixEvent(messageEvent({ + body: "hello", + eventId: "$hello", + roomId: "!created:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ + dispatched: true, + handlers: 1, + roomId: "!created:example", + }); + + await expect(bridge.dispatchMatrixEvent(reactionEvent({ + eventId: "$approve", + key: "approval.allow_once", + relatesTo: "approval_1", + roomId: "!created:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ dispatched: true, kind: "reaction" }); + expect(transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1", + decision: "approve", + }); + + await expect(bridge.dispatchMatrixEvent(messageEvent({ + body: "/import", + eventId: "$import", + roomId: "!created:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ dispatched: true }); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$import", + message: "/import", + sessionKey: "session_1", + })); + }); +}); + +function fakeTransport(options: { + events?: OpenClawGatewayEvent[]; + responses: Record; +}): OpenClawRuntimeRequestSurface & { request: ReturnType; responses: Record } { + return { + async *events(filter?: (event: OpenClawGatewayEvent) => boolean) { + for (const event of options.events ?? []) { + if (!filter || filter(event)) yield event; + } + }, + request: vi.fn(async (method: string) => options.responses[method]), + responses: options.responses, + }; +} + +function matrixConfig() { + return { + account: { + accessToken: "matrix-token", + deviceId: "DEVICE", + homeserver: "https://matrix.example", + userId: "@sh-openclawbot:example", + }, + store: {} as never, + }; +} + +function messageEvent(options: { body: string; eventId: string; roomId: string; sender: string }): MatrixMessageEvent { + return { + attachments: [], + class: "message", + content: { body: options.body, msgtype: "m.text" }, + edited: false, + encrypted: false, + eventId: options.eventId, + kind: "message", + messageType: "m.text", + raw: {}, + roomId: options.roomId, + sender: { isMe: false, userId: options.sender }, + text: options.body, + type: "m.room.message", + }; +} + +function editEvent(options: { body: string; eventId: string; replaces: string; roomId: string; sender: string }): MatrixMessageEvent { + return { + attachments: [], + class: "message", + content: { + body: `* ${options.body}`, + "m.new_content": { body: options.body, msgtype: "m.text" }, + "m.relates_to": { event_id: options.replaces, rel_type: "m.replace" }, + msgtype: "m.text", + }, + edited: true, + encrypted: false, + eventId: options.eventId, + kind: "message", + messageType: "m.text", + raw: {}, + replaces: options.replaces, + roomId: options.roomId, + sender: { isMe: false, userId: options.sender }, + text: options.body, + type: "m.room.message", + }; +} + +function reactionEvent(options: { eventId: string; key: string; relatesTo: string; roomId: string; sender: string }): MatrixClientEvent { + return { + added: true, + class: "message", + content: { + "m.relates_to": { + event_id: options.relatesTo, + key: options.key, + rel_type: "m.annotation", + }, + }, + eventId: options.eventId, + key: options.key, + kind: "reaction", + raw: {}, + relatesTo: options.relatesTo, + roomId: options.roomId, + sender: { isMe: false, userId: options.sender }, + type: "m.reaction", + }; +} + +function redactionEvent(options: { eventId: string; redacts: string; roomId: string; sender: string }): MatrixClientEvent { + return { + class: "unknown", + content: {}, + eventId: options.eventId, + kind: "redaction", + raw: { redacts: options.redacts }, + roomId: options.roomId, + sender: { isMe: false, userId: options.sender }, + type: "m.room.redaction", + } as MatrixClientEvent; +} + +function receiptEvent(options: { eventId: string; roomId: string; sender: string }): MatrixClientEvent { + return { + class: "ephemeral", + content: { + [options.eventId]: { + "m.read": { + [options.sender]: { ts: 1 }, + }, + }, + }, + kind: "receipt", + raw: {}, + roomId: options.roomId, + type: "m.receipt", + } as MatrixClientEvent; +} + +function markedUnreadEvent(options: { roomId: string; sender: string; unread: boolean }): MatrixClientEvent { + return { + class: "accountData", + content: { unread: options.unread }, + kind: "accountData", + raw: {}, + roomId: options.roomId, + sender: { isMe: false, userId: options.sender }, + type: "m.marked_unread", + } as MatrixClientEvent; +} + +function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscription & { stop: ReturnType } } { + const subscription = { + catchUp: vi.fn(async () => {}), + done: Promise.resolve(), + stop: vi.fn(async () => {}), + }; + const beeperStreams = { + finalizeMessage: vi.fn(async () => ({ + eventId: "$stream-root", + raw: {}, + replacementEventId: "$stream-final", + roomId: "!created:example", + })), + publishPart: vi.fn(async () => ({})), + startMessage: vi.fn(async () => ({ + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + roomId: "!created:example", + })), + }; + const beeperAIRunStreams = { + appendEvent: vi.fn(async ({ event, runId }: { event: Record; runId: string }) => ({ + body: "...", + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + events: [event], + finalAIMessage: {}, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: {}, + raw: {}, + roomId: "!created:example", + runId, + threadId: runId, + })), + error: vi.fn(async ({ message, runId }: { message?: string; runId: string }) => ({ + body: message ?? "failed", + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + events: [{ message, runId, type: "RUN_ERROR" }], + finalAIMessage: {}, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: {}, + raw: {}, + replacementEventId: "$stream-final", + roomId: "!created:example", + runId, + threadId: runId, + })), + finish: vi.fn(async ({ finishReason, runId }: { finishReason?: string; runId: string }) => ({ + body: "...", + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + events: [{ finishReason: finishReason ?? "stop", runId, type: "RUN_FINISHED" }], + finalAIMessage: {}, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: {}, + raw: {}, + replacementEventId: "$stream-final", + roomId: "!created:example", + runId, + threadId: runId, + })), + start: vi.fn(async ({ runId }: { runId: string }) => ({ + body: "...", + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + events: [ + { runId, threadId: runId, type: "RUN_STARTED" }, + { messageId: `msg-${runId}`, role: "assistant", type: "TEXT_MESSAGE_START" }, + ], + finalAIMessage: {}, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: {}, + raw: {}, + roomId: "!created:example", + runId, + threadId: runId, + })), + }; + return { + accountData: {} as MatrixClient["accountData"], + appservice: { + batchSend: vi.fn(async () => ({ eventIds: ["$batch"], raw: {} })), + createManagementRoom: vi.fn(async () => ({ raw: {}, roomId: "!created:example" })), + createPortalRoom: vi.fn(async () => ({ raw: {}, roomId: "!created:example" })), + createRoom: vi.fn(async () => ({ raw: {}, roomId: "!created:example" })), + ensureJoined: vi.fn(async () => {}), + ensureRegistered: vi.fn(async () => {}), + init: vi.fn(async () => ({ botUserId: "@sh-openclawbot:example", id: "openclaw" })), + sendMessage: vi.fn(async () => ({ eventId: "$sent", raw: {}, roomId: "!room:example" })), + setProfile: vi.fn(async () => {}), + }, + beeper: { aiRunStreams: beeperAIRunStreams, streams: beeperStreams } as unknown as MatrixClient["beeper"], + boot: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@sh-openclawbot:example" })), + close: vi.fn(async () => {}), + crypto: {} as MatrixClient["crypto"], + logout: vi.fn(async () => {}), + media: {} as MatrixClient["media"], + messages: {} as MatrixClient["messages"], + raw: { + request: vi.fn(async () => ({ body: { event_id: "$sent" }, raw: { event_id: "$sent" }, status: 200 })), + } as unknown as MatrixClient["raw"], + reactions: {} as MatrixClient["reactions"], + receipts: {} as MatrixClient["receipts"], + rooms: {} as MatrixClient["rooms"], + streams: {} as MatrixClient["streams"], + subscribe: vi.fn(async (_filter, _handler: (event: MatrixClientEvent) => void | Promise) => subscription), + subscription, + sync: {} as MatrixClient["sync"], + toDevice: {} as MatrixClient["toDevice"], + typing: {} as MatrixClient["typing"], + users: { + get: vi.fn(async ({ userId }) => ({ raw: {}, userId })), + getOwnAvatarUrl: vi.fn(async () => ({})), + getOwnDisplayName: vi.fn(async () => ({ raw: {} })), + setOwnAvatarUrl: vi.fn(async () => {}), + setOwnDisplayName: vi.fn(async () => {}), + }, + whoami: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@sh-openclawbot:example" })), + }; +} diff --git a/packages/openclaw/src/matrix-parser.ts b/packages/openclaw/src/matrix-parser.ts new file mode 100644 index 0000000..01d249b --- /dev/null +++ b/packages/openclaw/src/matrix-parser.ts @@ -0,0 +1,175 @@ +import type { MatrixMessage } from "@beeper/pickle-bridge/types"; + +export interface ParsedMatrixTextMessage { + attachments: unknown[]; + command?: { + args: string; + name: string; + }; + formattedBody?: string; + mentions?: { room?: boolean; userIds?: string[] }; + replyQuote?: { + body?: string; + sender?: string; + }; + replyToEventId?: string; + text: string; + threadRootEventId?: string; +} + +export function parseMatrixTextMessage( + text: string, + content: unknown, + msg?: Pick, +): ParsedMatrixTextMessage { + const contentRecord = recordValue(content); + const newContent = recordValue(contentRecord?.["m.new_content"]); + const messageContent = newContent ?? contentRecord; + const relates = recordValue(contentRecord?.["m.relates_to"]); + const effectiveText = stringValue(messageContent?.body) ?? text; + const replyToEventId = + stringValue(msg?.replyTo?.id) ?? + stringValue(msg?.event.replyTo) ?? + stringValue(recordValue(relates?.["m.in_reply_to"])?.event_id) ?? + (relates?.rel_type === "m.thread" ? stringValue(relates.event_id) : undefined); + const threadRootEventId = stringValue(msg?.threadRoot?.id) ?? stringValue(msg?.event.threadRoot) ?? (relates?.rel_type === "m.thread" ? stringValue(relates.event_id) : undefined); + const fallback = extractMatrixReplyFallback(effectiveText); + const body = fallback.body; + const command = parseSlashCommand(body) ?? parseSlashCommand(stripLeadingMatrixMention(body)); + const formattedBody = stripMatrixHtmlReplyFallback(stringValue(messageContent?.formatted_body) ?? stringValue(msg?.event.html)); + const mentions = normalizeMentions(messageContent?.["m.mentions"] ?? contentRecord?.["m.mentions"] ?? msg?.event.mentions); + const attachments = normalizeMatrixAttachments(msg?.attachments ?? msg?.event.attachments ?? [], messageContent ?? content); + return { + attachments, + ...(command ? { command } : {}), + ...(formattedBody ? { formattedBody } : {}), + ...(mentions ? { mentions } : {}), + ...(fallback.quote ? { replyQuote: fallback.quote } : {}), + ...(replyToEventId ? { replyToEventId } : {}), + text: body, + ...(threadRootEventId ? { threadRootEventId } : {}), + }; +} + +function stripMatrixHtmlReplyFallback(html: string | undefined): string | undefined { + if (!html) return undefined; + const stripped = html.replace(/^\s*[\s\S]*?<\/mx-reply>\s*/iu, "").trim(); + return stripped || undefined; +} + +function normalizeMatrixAttachments(attachments: unknown[], content: unknown): unknown[] { + const normalized: unknown[] = attachments.flatMap((attachment) => { + const record = recordValue(attachment); + if (!record) return []; + return [stripUndefined({ + contentType: record.contentType, + contentUri: record.contentUri, + duration: record.duration, + encryptedFile: record.encryptedFile, + filename: record.filename, + height: record.height, + kind: record.kind, + size: record.size, + width: record.width, + })]; + }); + const contentUri = stringValue(recordValue(content)?.url); + if (normalized.length === 0 && contentUri) { + normalized.push(stripUndefined({ + contentUri, + filename: stringValue(recordValue(content)?.filename) ?? stringValue(recordValue(content)?.body), + kind: matrixAttachmentKind(stringValue(recordValue(content)?.msgtype)), + })); + } + return normalized; +} + +function matrixAttachmentKind(msgtype: string | undefined): string | undefined { + switch (msgtype) { + case "m.image": + return "image"; + case "m.video": + return "video"; + case "m.audio": + return "audio"; + case "m.file": + return "file"; + default: + return undefined; + } +} + +function normalizeMentions(value: unknown): ParsedMatrixTextMessage["mentions"] | undefined { + const record = recordValue(value); + if (!record) return undefined; + const mentions: { room?: boolean; userIds?: string[] } = {}; + if (record.room === true) mentions.room = true; + if (Array.isArray(record.user_ids)) mentions.userIds = record.user_ids.filter((item): item is string => typeof item === "string"); + if (Array.isArray(record.userIds)) mentions.userIds = record.userIds.filter((item): item is string => typeof item === "string"); + return mentions.room || mentions.userIds?.length ? mentions : undefined; +} + +function extractMatrixReplyFallback(text: string): { + body: string; + quote?: { + body?: string; + sender?: string; + }; +} { + const lines = text.replace(/\r\n?/gu, "\n").split("\n"); + let index = 0; + while (index < lines.length && lines[index]?.startsWith(">")) index += 1; + const quotedLines = lines.slice(0, index).map((line) => line.replace(/^>\s?/u, "")); + if (index > 0 && lines[index] === "") index += 1; + const body = lines.slice(index).join("\n").trim(); + const quote = parseMatrixReplyQuote(quotedLines); + return { + body, + ...(quote ? { quote } : {}), + }; +} + +function parseMatrixReplyQuote(lines: string[]): { body?: string; sender?: string } | undefined { + const text = lines.join("\n").trim(); + if (!text) return undefined; + const firstLine = lines[0]?.trim() ?? ""; + const senderMatch = /^<([^>]+)>\s?(.*)$/su.exec(firstLine); + const sender = senderMatch?.[1]?.trim(); + const firstBody = senderMatch?.[2] ?? firstLine; + const rest = lines.slice(1); + const body = [firstBody, ...rest].join("\n").trim(); + return stripUndefined({ + ...(body ? { body } : {}), + ...(sender ? { sender } : {}), + }); +} + +function parseSlashCommand(text: string): ParsedMatrixTextMessage["command"] | undefined { + if (!text.startsWith("/") || text.startsWith("//")) return undefined; + const match = /^\/([A-Za-z][\w-]*)(?:\s+(.*))?$/su.exec(text.trim()); + if (!match) return undefined; + return { + args: match[2] ?? "", + name: match[1]!.toLowerCase(), + }; +} + +function stripLeadingMatrixMention(text: string): string { + return text.trimStart().replace(/^@[^\s:]+(?::[^\s]+)?\s+/u, ""); +} + +function recordValue(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function stripUndefined>(input: T): T { + for (const key of Object.keys(input)) { + if (input[key] === undefined) delete input[key]; + } + return input; +} diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts new file mode 100644 index 0000000..5bb71aa --- /dev/null +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -0,0 +1,241 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import extension, { openClawBeeperPlugin } from "./plugin-entry"; + +describe("OpenClaw plugin package metadata", () => { + it("exports a loadable OpenClaw plugin object", () => { + const registered: unknown[] = []; + openClawBeeperPlugin.register({ + registerChannel(registration) { + registered.push(registration.plugin); + }, + channels: { + register(plugin) { + registered.push(plugin); + }, + }, + }); + expect(extension.id).toBe("beeper"); + expect(extension.kind).toBe("bundled-channel-entry"); + const loadedPlugin = extension.loadChannelPlugin(); + expect(loadedPlugin.id).toBe("beeper"); + expect(extension.loadChannelSecrets()).toMatchObject({ + secretTargetRegistryEntries: expect.arrayContaining([ + expect.objectContaining({ pathPattern: "channels.beeper.accounts.*.asToken" }), + expect.objectContaining({ pathPattern: "channels.beeper.accounts.*.hsToken" }), + ]), + }); + const runtimeRegistration = resolveBundledRuntimeChannelRegistration(extension); + expect(runtimeRegistration.id).toBe("beeper"); + expect(runtimeRegistration.plugin.id).toBe("beeper"); + expect(runtimeRegistration.plugin.setupWizard).toEqual(expect.any(Object)); + expect(registered).toHaveLength(1); + const [registeredPlugin] = registered as Array; + expect(registeredPlugin.id).toBe("beeper"); + expect(registeredPlugin.capabilities.reactions).toBe(true); + expect(registeredPlugin.capabilities.threads).toBe(true); + expect(registeredPlugin.message?.live?.capabilities.nativeStreaming).toBe(true); + expect(registeredPlugin.messaging).toEqual(expect.any(Object)); + expect(registeredPlugin.setup).toEqual(expect.any(Object)); + expect(registeredPlugin.setupWizard).toEqual(expect.any(Object)); + expect(registeredPlugin.threading).toEqual(expect.any(Object)); + }, 60_000); + + it("honors SDK channel registration modes", () => { + const registerChannel = vi.fn(); + openClawBeeperPlugin.register({ + registerChannel, + registrationMode: "cli-metadata", + } as never); + expect(registerChannel).not.toHaveBeenCalled(); + + openClawBeeperPlugin.register({ + registerChannel, + registrationMode: "discovery", + runtime: { marker: "runtime" }, + } as never); + expect(registerChannel).toHaveBeenCalledTimes(1); + expect(registerChannel).toHaveBeenCalledWith({ + plugin: expect.objectContaining({ id: "beeper" }), + }); + }); + + it("declares ClawHub install metadata and a package manifest", async () => { + const packageJson = JSON.parse(await readFile(resolve("package.json"), "utf8")) as { + files?: string[]; + openclaw?: { + extensions?: string[]; + runtimeExtensions?: string[]; + setupEntry?: string; + runtimeSetupEntry?: string; + channel?: { + cliAddOptions?: Array<{ flags?: string; description?: string }>; + configuredState?: { specifier?: string; exportName?: string }; + id?: string; + persistedAuthState?: { specifier?: string; exportName?: string }; + }; + install?: { clawhubSpec?: string; defaultChoice?: string; npmSpec?: string }; + compat?: { pluginApi?: string }; + }; + peerDependencies?: { openclaw?: string }; + scripts?: Record; + version?: string; + }; + const manifest = JSON.parse(await readFile(resolve("openclaw.plugin.json"), "utf8")) as { + activation?: { onStartup?: boolean }; + id?: string; + channels?: string[]; + channelConfigs?: Record; + schema?: { properties?: Record }; + uiHints?: Record; + }>; + configSchema?: { + properties?: Record; + }; + uiHints?: Record; + channelEnvVars?: Record; + }; + const schema = JSON.parse(await readFile(resolve("src/beeper-channel-config.schema.json"), "utf8")); + + expect(packageJson.files).toContain("openclaw.plugin.json"); + expect(packageJson.files).not.toContain("skills"); + expect(packageJson.openclaw?.extensions).toEqual(["./src/plugin-entry.ts"]); + expect(packageJson.openclaw?.runtimeExtensions).toEqual(["./dist/plugin-entry.mjs"]); + expect(packageJson.openclaw?.setupEntry).toBe("./src/setup-entry.ts"); + expect(packageJson.openclaw?.runtimeSetupEntry).toBe("./dist/setup-entry.mjs"); + expect(packageJson.openclaw?.channel?.id).toBe("beeper"); + expect(packageJson.openclaw?.channel?.configuredState).toEqual({ + specifier: "./auth-presence", + exportName: "hasAnyBeeperConfiguredState", + }); + expect(packageJson.openclaw?.channel?.persistedAuthState).toEqual({ + specifier: "./auth-presence", + exportName: "hasAnyBeeperAuth", + }); + expect(packageJson.openclaw?.channel?.cliAddOptions).toEqual([ + { + flags: "--server-env ", + description: "Beeper server environment: prod, staging, dev, or local", + }, + ]); + expect(packageJson.openclaw?.install?.defaultChoice).toBe("clawhub"); + expect(packageJson.openclaw?.install?.clawhubSpec).toBe("clawhub:@beeper/openclaw"); + expect(packageJson.openclaw?.install?.npmSpec).toBe("@beeper/openclaw"); + expect(packageJson.openclaw?.compat?.pluginApi).toBe(">=2026.6.2"); + expect(packageJson.peerDependencies?.openclaw).toBe(">=2026.6.2"); + expect(packageJson.scripts?.prepublishOnly).toBe("node ../../scripts/guard-pnpm-publish.mjs"); + expect(packageJson.files).toContain("dist"); + expect(manifest).toEqual(expect.objectContaining({ + id: "beeper", + channels: ["beeper"], + })); + expect(manifest.activation?.onStartup).toBe(false); + expect(manifest.channelEnvVars).toBeUndefined(); + expect(manifest.uiHints).toBeUndefined(); + expect(manifest.configSchema).toEqual({ + type: "object", + additionalProperties: false, + properties: {}, + }); + expect(manifest.channelConfigs?.beeper?.schema).toEqual(schema); + expect(manifest.configSchema?.properties).not.toHaveProperty("streamFinalization"); + expect(manifest.channelConfigs?.beeper).toMatchObject({ + commands: { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: true, + }, + schema: { + additionalProperties: false, + properties: expect.objectContaining({ + accounts: expect.any(Object), + agents: expect.any(Object), + defaultAccount: expect.any(Object), + }), + }, + uiHints: expect.objectContaining({ + "accounts.*.asToken": expect.objectContaining({ sensitive: true, tags: ["hidden"] }), + "accounts.*.hsToken": expect.objectContaining({ sensitive: true, tags: ["hidden"] }), + "accounts.*.serverEnv": expect.objectContaining({ + help: expect.stringContaining("Choose before Beeper login"), + }), + }), + }); + expect(manifest.channelConfigs?.beeper?.schema?.properties).not.toHaveProperty("asToken"); + expect(manifest.channelConfigs?.beeper?.schema?.properties).not.toHaveProperty("hsToken"); + expect(manifest.channelConfigs?.beeper?.schema?.properties).not.toHaveProperty("serverEnv"); + expect(manifest.channelConfigs?.beeper?.schema?.properties?.accounts).toMatchObject({ + additionalProperties: { + properties: { + asToken: expect.any(Object), + bridge: expect.any(Object), + hsToken: expect.any(Object), + serverEnv: expect.objectContaining({ enum: ["prod", "staging", "dev", "local"] }), + }, + }, + }); + }); + + it("keeps the public package manifest publishable and installable from built files", async () => { + const packageJson = JSON.parse(await readFile(resolve("package.json"), "utf8")) as { + bin?: Record; + dependencies?: Record; + devDependencies?: Record; + files?: string[]; + main?: string; + openclaw?: { + runtimeExtensions?: string[]; + runtimeSetupEntry?: string; + }; + }; + const npmIgnore = await readFile(resolve(".npmignore"), "utf8"); + const dependencies = Object.entries(packageJson.dependencies ?? {}); + const devDependencies = Object.entries(packageJson.devDependencies ?? {}); + + expect(packageJson.files).toContain("dist"); + expect(npmIgnore.split(/\r?\n/)).toEqual(expect.arrayContaining([ + "src", + "!dist", + "!dist/**", + ])); + expect(packageJson.main).toBe("./dist/plugin-entry.mjs"); + expect(packageJson.bin?.["pickle-openclaw"]).toBe("./dist/cli.mjs"); + expect(packageJson.openclaw?.runtimeExtensions).toEqual(["./dist/plugin-entry.mjs"]); + expect(packageJson.openclaw?.runtimeSetupEntry).toBe("./dist/setup-entry.mjs"); + expect(dependencies).toEqual([]); + expect(devDependencies).toEqual(expect.arrayContaining([ + ["@beeper/pickle-ag-ui", "workspace:^"], + ["@beeper/pickle-bridge", "workspace:^"], + ["@beeper/pickle-state-file", "workspace:^"], + ])); + expect(devDependencies.some(([name]) => name === "@beeper/pickle")).toBe(false); + expect(devDependencies.find(([, version]) => version === "workspace:*")).toBeUndefined(); + }); +}); + +function resolveBundledRuntimeChannelRegistration(moduleExport: unknown): { id?: string; plugin?: unknown } { + const resolved = unwrapDefaultModuleExport(moduleExport); + if (!resolved || typeof resolved !== "object") return {}; + const entry = resolved as { + id?: unknown; + loadChannelPlugin?: () => unknown; + }; + if ( + typeof entry.id !== "string" || + typeof entry.loadChannelPlugin !== "function" + ) { + return {}; + } + return { + id: entry.id, + plugin: entry.loadChannelPlugin(), + }; +} + +function unwrapDefaultModuleExport(value: unknown): unknown { + if (value && typeof value === "object" && "default" in value) { + return (value as { default?: unknown }).default; + } + return value; +} diff --git a/packages/openclaw/src/openclaw-identity.ts b/packages/openclaw/src/openclaw-identity.ts new file mode 100644 index 0000000..158da48 --- /dev/null +++ b/packages/openclaw/src/openclaw-identity.ts @@ -0,0 +1,33 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; + +export async function resolveOpenClawDeviceId(options: { dataDir?: string; env?: NodeJS.ProcessEnv } = {}): Promise { + const env = options.env ?? process.env; + const fromEnv = firstNonEmpty(env.PICKLE_OPENCLAW_DEVICE_ID, env.OPENCLAW_DEVICE_ID); + if (fromEnv) return fromEnv; + const candidates = [ + resolve(homedir(), ".openclaw", "identity", "device.json"), + ...(options.dataDir ? [resolve(options.dataDir, "openclaw-device.json")] : []), + ...(options.dataDir ? [resolve(options.dataDir, "gateway-device.json")] : []), + ]; + for (const path of candidates) { + const deviceId = await readDeviceId(path); + if (deviceId) return deviceId; + } + throw new Error("OpenClaw device id not found; pair or start OpenClaw before Beeper login setup."); +} + +async function readDeviceId(path: string): Promise { + try { + const raw = JSON.parse(await readFile(path, "utf8")) as { deviceId?: unknown; nodeId?: unknown }; + const value = typeof raw.deviceId === "string" ? raw.deviceId : typeof raw.nodeId === "string" ? raw.nodeId : undefined; + return value?.trim() || undefined; + } catch { + return undefined; + } +} + +function firstNonEmpty(...values: Array): string | undefined { + return values.find((value): value is string => Boolean(value?.trim()))?.trim(); +} diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts new file mode 100644 index 0000000..c809b41 --- /dev/null +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -0,0 +1,1003 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { BeeperChannelRuntime, setBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; +import { BeeperTurnStream } from "@beeper/pickle-bridge/beeper-stream"; +import { createDefaultConfig } from "./config"; +import { + createOpenClawHostRuntimeAdapter, + OpenClawPluginRuntimeAdapter, + type OpenClawGatewayEvent, + type OpenClawRuntimeRequestSurface, +} from "./openclaw-runtime"; + +describe("OpenClawPluginRuntimeAdapter", () => { + it("lists OpenClaw agents as Matrix ghost contacts", async () => { + const transport = fakeTransport({ + "agents.list": { agents: [{ description: "Code", id: "codex", name: "Codex" }] }, + }); + const runtime = new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw", homeserver: "https://matrix.example" }), + transport, + }); + + await expect(runtime.listAgentContacts()).resolves.toEqual([ + { + agentId: "codex", + description: "Code", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:matrix.example", + }, + ]); + expect(transport.request).toHaveBeenCalledWith("agents.list", {}); + }); + + it("uses the agent id as the default ghost name when OpenClaw has no explicit agent list", async () => { + const transport = createOpenClawHostRuntimeAdapter({ + config: { + current: () => ({ + agents: { + defaults: { workspace: "/tmp/openclaw/workspace" }, + }, + }), + }, + }); + + await expect(transport.request("agents.list", {})).resolves.toEqual({ + agents: [{ id: "main", displayName: "main" }], + }); + }); + + it("creates sessions through OpenClaw RPC and rejects sends without a host channel runtime", async () => { + const transport = fakeTransport({ + "sessions.create": { key: "agent:codex:main", sessionId: "session_1" }, + }); + const runtime = new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }); + + await expect(runtime.createSession({ agentId: "codex", label: "Main" })).resolves.toEqual({ + agentId: "codex", + key: "agent:codex:main", + label: "Main", + raw: { key: "agent:codex:main", sessionId: "session_1" }, + sessionId: "session_1", + }); + await expect(runtime.sendMessage({ message: "hello", sessionKey: "agent:codex:main", timeoutMs: 1000 })) + .rejects.toThrow("OpenClaw Beeper turns require OpenClaw channel inbound helpers"); + }); + + it("patches session reasoning after create when Beeper needs rich reasoning events", async () => { + const transport = fakeTransport({ + "sessions.create": { key: "agent:codex:main", sessionId: "session_1" }, + "sessions.patch": { ok: true }, + }); + const runtime = new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }); + + await expect(runtime.createSession({ agentId: "codex", label: "Main", reasoningLevel: "stream" })).resolves.toMatchObject({ + agentId: "codex", + key: "agent:codex:main", + label: "Main", + }); + expect(transport.request).toHaveBeenCalledWith("sessions.create", { + agentId: "codex", + label: "Main", + }); + expect(transport.request).toHaveBeenCalledWith("sessions.patch", { + agentId: "codex", + key: "agent:codex:main", + reasoningLevel: "stream", + }); + }); + + it("filters gateway events by run id and resolves approvals", async () => { + const events: OpenClawGatewayEvent[] = [ + { event: "assistant.delta", payload: { delta: "skip", runId: "run_other" } }, + { event: "assistant.delta", payload: { delta: "use", runId: "run_1" } }, + ]; + const transport = fakeTransport({ + "exec.approval.resolve": { ok: true }, + "plugin.approval.resolve": { plugin: true }, + }, events); + const runtime = new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }); + + await expect(runtime.resolveApproval({ approvalId: "approval_1", decision: "approve" })).resolves.toEqual({ ok: true }); + expect(transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1", + decision: "approve", + }); + await expect(runtime.resolveApproval({ approvalId: "plugin:approval_2", approvalKind: "plugin", decision: "deny" })).resolves.toEqual({ plugin: true }); + expect(transport.request).toHaveBeenCalledWith("plugin.approval.resolve", { + approvalId: "plugin:approval_2", + decision: "deny", + }); + }); + + it("keeps generic host requests and event surface available", async () => { + const runtimeEvents: OpenClawGatewayEvent[] = [ + { event: "session.message", payload: { runId: "skip" } }, + { event: "session.message", payload: { runId: "run_1" }, seq: 3 }, + ]; + const host = { + async *events(filter?: (event: OpenClawGatewayEvent) => boolean) { + for (const event of runtimeEvents) { + if (!filter || filter(event)) yield event; + } + }, + request: vi.fn(async (method: string) => ({ method, runId: "run_1" })), + }; + const transport = createOpenClawHostRuntimeAdapter(host); + + await expect(transport.request("exec.approval.resolve", { approvalId: "approval_1", decision: "approve" })).resolves.toEqual({ + method: "exec.approval.resolve", + runId: "run_1", + }); + expect(host.request).toHaveBeenCalledWith("exec.approval.resolve", { approvalId: "approval_1", decision: "approve" }, undefined); + + const received: OpenClawGatewayEvent[] = []; + for await (const event of transport.events((candidate) => { + const payload = candidate.payload as { runId?: string }; + return payload.runId === "run_1"; + })) { + received.push(event); + } + expect(received).toEqual([{ event: "session.message", payload: { runId: "run_1" }, seq: 3 }]); + }); + + it("sends host-backed Beeper turns through channel helpers", async () => { + const aiRunStreams = createTestBeeperAIRunStreams(); + const request = vi.fn(async () => { + throw new Error("generic request should not be used"); + }); + let resolveRun: (() => void) | undefined; + const runDone = new Promise((resolve) => { + resolveRun = resolve; + }); + const dispatchReply = vi.fn(async (params: Record) => { + const delivery = params.delivery as { deliver?: (payload: unknown, info?: unknown) => Promise }; + await delivery.deliver?.("direct final", { kind: "final" }); + resolveRun?.(); + }); + const hostRuntime = { + request, + channel: { + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + session: { + recordInboundSession: vi.fn(), + resolveStorePath: () => "/tmp/openclaw", + }, + inbound: { + buildContext: vi.fn((params) => params), + dispatchReply, + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + }; + setBeeperChannelRuntimeForHost(hostRuntime, createTestBeeperChannelRuntime(aiRunStreams)); + const runtime = new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport: createOpenClawHostRuntimeAdapter(hostRuntime), + }); + + const sent = await runtime.sendMessage({ + idempotencyKey: "$event", + matrix: { accountId: "@batuhan:beeper.com", roomId: "!room:example", sender: "@alice:example" }, + message: "hello", + sessionKey: "agent:main:beeper:default:direct:!room:example", + }); + + expect(sent.runId).toMatch(/^beeper:/u); + await runDone; + expect(request).not.toHaveBeenCalled(); + expect(dispatchReply).toHaveBeenCalledTimes(1); + expect(aiRunStreams.start).toHaveBeenCalledTimes(1); + expect(aiRunStreams.finish).toHaveBeenCalledWith(expect.objectContaining({ + runId: sent.runId, + })); + setBeeperChannelRuntimeForHost(hostRuntime, undefined); + }); + + it("adapts OpenClaw plugin runtime helpers when no gateway request surface exists", async () => { + const transport = createOpenClawHostRuntimeAdapter({ + agent: { + session: { + listSessionEntries: () => [ + { + sessionKey: "agent:main:dashboard:one", + entry: { + agentId: "main", + chatType: "direct", + label: "One", + lastChannel: "webchat", + origin: { provider: "webchat", surface: "webchat" }, + sessionFile: "/tmp/session.jsonl", + updatedAt: 123, + }, + }, + ], + }, + }, + config: { + current: () => ({ + agents: { + list: [{ id: "main", name: "Main Agent" }], + }, + }), + }, + }); + + await expect(transport.request("agents.list", {})).resolves.toEqual({ + agents: [{ id: "main", displayName: "Main Agent" }], + }); + await expect(transport.request("sessions.list", { includeArchived: true })).resolves.toEqual({ + sessions: [{ + agentId: "main", + chatType: "direct", + displayName: "One", + key: "agent:main:dashboard:one", + label: "One", + lastChannel: "webchat", + lastProvider: "webchat", + origin: { provider: "webchat", surface: "webchat" }, + provider: "webchat", + sessionFile: "/tmp/session.jsonl", + updatedAt: 123, + }], + }); + await expect(transport.request("chat.history", { sessionKey: "agent:main:dashboard:one" })).resolves.toEqual({ + messages: [], + }); + }); + + it("rejects Beeper-originated sends when the OpenClaw channel runtime is unavailable", async () => { + const transport = createOpenClawHostRuntimeAdapter({ + agent: { + resolveAgentDir: () => "/tmp/agent", + session: { + getSessionEntry: () => ({ + sessionFile: "/tmp/session.jsonl", + sessionId: "session-1", + }), + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + }); + + await expect(transport.sendMessage({ + sessionKey: "agent:main:beeper:room", + message: "from Beeper", + idempotencyKey: "$event", + })).rejects.toThrow("OpenClaw Beeper requires OpenClaw channel inbound helpers"); + }); + + it("runs Beeper-originated sends through OpenClaw channel inbound helpers for live AG-UI progress", async () => { + const aiRunStreams = createTestBeeperAIRunStreams(); + const dispatchReply = vi.fn(async (params: Record) => { + const replyOptions = params.replyOptions as Record void | Promise>; + await replyOptions.onReasoningStream?.({ text: "checking" }); + await replyOptions.onReasoningStream?.({ delta: " delta-only thinking" }); + await replyOptions.onToolStart?.({ args: { path: "README.md" }, name: "read_file", phase: "start", toolCallId: "real-tool-id" }); + await replyOptions.onCommandOutput?.({ name: "read_file", output: "ok", phase: "end", status: "completed", toolCallId: "real-tool-id" }); + await replyOptions.onApprovalEvent?.({ + approvalId: "approval_1", + message: "Run command?", + phase: "requested", + toolCallId: "tool_1", + }); + await replyOptions.onPartialReply?.({ text: "hello" }); + const delivery = params.delivery as { deliver?: (payload: unknown) => Promise }; + await delivery.deliver?.({ + parts: [ + { content: "{\"status\":\"completed\",\"query\":\"docs\"}", type: "tool-call" }, + { content: "hello world", type: "text" }, + ], + }); + return { dispatchResult: { queuedFinal: true } }; + }); + const hostRuntime = { + channel: { + reply: { + dispatchReplyWithBufferedBlockDispatcher: vi.fn(), + }, + session: { + recordInboundSession: vi.fn(), + resolveStorePath: () => "/tmp/sessions.json", + }, + inbound: { + buildContext: (params: Record) => ({ + Body: "from Beeper", + BodyForAgent: "from Beeper", + From: "beeper", + RawBody: "from Beeper", + SessionKey: (params.route as { routeSessionKey?: string }).routeSessionKey, + To: "beeper", + }), + dispatchReply, + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + }; + setBeeperChannelRuntimeForHost(hostRuntime, createTestBeeperChannelRuntime(aiRunStreams)); + const transport = createOpenClawHostRuntimeAdapter(hostRuntime); + + const received: OpenClawGatewayEvent[] = []; + let observedRunId: string | undefined; + const done = (async () => { + for await (const event of transport.events((candidate) => { + const payload = candidate.payload as { runId?: string }; + return !observedRunId || payload.runId === observedRunId; + })) { + received.push(event); + if (received.some((event) => event.event === "run.completed")) break; + } + })(); + const sent = await transport.sendMessage({ + sessionKey: "agent:main:beeper:room", + message: "from Beeper", + idempotencyKey: "$event", + matrix: { accountId: "@batuhan:beeper.com", roomId: "!room:example", sender: "@alice:example" }, + }); + observedRunId = (sent as { runId?: string }).runId; + await done; + + expect(dispatchReply).toHaveBeenCalledWith(expect.objectContaining({ + accountId: "@batuhan:beeper.com", + agentId: "main", + channel: "beeper", + routeSessionKey: "agent:main:beeper:room", + })); + expect((dispatchReply.mock.calls[0]?.[0] as { replyOptions?: Record } | undefined)?.replyOptions).toMatchObject({ + disableBlockStreaming: false, + reasoningLevelOverride: "stream", + sourceReplyDeliveryMode: "automatic", + verboseLevelOverride: "full", + }); + expect(received).toEqual(expect.arrayContaining([ + expect.objectContaining({ event: "thinking.delta" }), + expect.objectContaining({ event: "tool.call.started" }), + expect.objectContaining({ + event: "tool.call.completed", + payload: expect.objectContaining({ output: "ok", toolCallId: "real-tool-id" }), + }), + expect.objectContaining({ event: "approval.requested" }), + expect.objectContaining({ + event: "assistant.delta", + payload: expect.objectContaining({ delta: "hello" }), + }), + expect.objectContaining({ + event: "assistant.delta", + payload: expect.objectContaining({ delta: " world" }), + }), + expect.objectContaining({ event: "run.completed" }), + ])); + expect(aiRunStreams.start).toHaveBeenCalledTimes(1); + const streamParts = startedAndAppendedParts(aiRunStreams); + expect(streamParts.map((part) => part.kind)).toEqual(expect.arrayContaining([ + "reasoning", + "tool_start", + "tool_result", + "text", + ])); + expect(streamParts.filter((part) => part.kind === "reasoning").map((part) => part.text)).toEqual([ + "checking", + " delta-only thinking", + ]); + expect(aiRunStreams.appendEvent.mock.calls.map(([options]) => options.event.type)).toContain("CUSTOM"); + const toolOutput = streamParts.find((part) => part.kind === "tool_result" && part.output === "ok"); + expect(toolOutput).toMatchObject({ + toolCallId: "real-tool-id", + toolName: "read_file", + }); + expect(streamParts).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "text", text: expect.stringContaining("\"status\":\"completed\"") }), + ])); + expect(aiRunStreams.finish).toHaveBeenCalledWith(expect.objectContaining({ + runId: observedRunId, + })); + setBeeperChannelRuntimeForHost(hostRuntime, undefined); + }); + + it("preserves supported dummybridge-style tool ids and avoids replaying duplicate text callbacks", async () => { + const aiRunStreams = createTestBeeperAIRunStreams(); + const dispatchReply = vi.fn(async (params: Record) => { + const replyOptions = params.replyOptions as Record void | Promise>; + await replyOptions.onPartialReply?.({ text: "hel" }); + await replyOptions.onBlockReplyQueued?.({ text: "hel" }); + await replyOptions.onBlockReply?.({ text: "hello" }); + await replyOptions.onToolStart?.({ args: { path: "a.txt" }, name: "read_file", phase: "start", toolCallId: "tool-a" }); + await replyOptions.onToolStart?.({ args: { path: "b.txt" }, name: "read_file", phase: "start", toolCallId: "tool-b" }); + await replyOptions.onCommandOutput?.({ name: "read_file", output: "chunk-a", phase: "delta", status: "running", toolCallId: "tool-a" }); + await replyOptions.onCommandOutput?.({ name: "read_file", output: "done-a", phase: "end", status: "completed", toolCallId: "tool-a" }); + await replyOptions.onToolStart?.({ args: { query: "docs" }, name: "web_search", phase: "start", toolCallId: "web-1" }); + await replyOptions.onCommandOutput?.({ name: "web_search", phase: "end", queries: ["docs"], query: "docs", status: "completed", toolCallId: "web-1" }); + await replyOptions.onToolStart?.({ args: { query: "blog" }, name: "web_search", phase: "start", toolCallId: "web-2" }); + await replyOptions.onToolResult?.({ output: { queries: ["blog"], query: "blog", state: "complete", status: "completed" }, toolCallId: "web-2", toolName: "web_search" }); + await replyOptions.onToolResult?.({ result: { ok: true }, toolCallId: "tool-b", toolName: "read_file" }); + const delivery = params.delivery as { deliver?: (payload: unknown, info?: unknown) => Promise }; + await delivery.deliver?.({ text: "hello world https://example.com/final." }, { kind: "final" }); + return { dispatchResult: { queuedFinal: true } }; + }); + const hostRuntime = { + channel: { + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + session: { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/sessions.json" }, + inbound: { + buildContext: (params: Record) => ({ + Body: "from Beeper", + BodyForAgent: "from Beeper", + From: "beeper", + RawBody: "from Beeper", + SessionKey: (params.route as { routeSessionKey?: string }).routeSessionKey, + To: "beeper", + }), + dispatchReply, + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + }; + setBeeperChannelRuntimeForHost(hostRuntime, createTestBeeperChannelRuntime(aiRunStreams)); + const transport = createOpenClawHostRuntimeAdapter(hostRuntime); + + const done = (async () => { + for await (const event of transport.events()) { + if (event.event === "run.completed") break; + } + })(); + await transport.sendMessage({ + sessionKey: "agent:main:beeper:room", + message: "from Beeper", + matrix: { accountId: "@batuhan:beeper.com", roomId: "!room:example", sender: "@alice:example" }, + }); + await done; + + const parts = startedAndAppendedParts(aiRunStreams); + expect(parts.filter((part) => part.kind === "text").map((part) => part.text)).toEqual([ + "hel", + "lo", + " world https://example.com/final.", + ]); + expect(parts.filter((part) => part.kind === "tool_start").map((part) => [part.toolCallId, part.toolName])).toEqual([ + ["tool-a", "read_file"], + ["tool-b", "read_file"], + ["web-1", "web_search"], + ["web-2", "web_search"], + ]); + expect(parts.filter((part) => part.kind === "tool_result").map((part) => [part.toolCallId, part.output, part.preliminary])).toEqual([ + ["tool-a", "chunk-a", true], + ["tool-a", "done-a", false], + ["tool-b", { ok: true }, undefined], + ]); + expect(parts).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "custom", name: "com.beeper.source", value: expect.objectContaining({ sourceId: "https://example.com/final", title: "example.com", url: "https://example.com/final" }) }), + ])); + expect(parts).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ output: expect.objectContaining({ query: "docs" }), toolCallId: "web-1", kind: "tool_result" }), + expect.objectContaining({ output: expect.objectContaining({ query: "blog" }), toolCallId: "web-2", kind: "tool_result" }), + ])); + setBeeperChannelRuntimeForHost(hostRuntime, undefined); + }); + + it("does not replay visible text when OpenClaw emits duplicate assistant starts", async () => { + const aiRunStreams = createTestBeeperAIRunStreams(); + const dispatchReply = vi.fn(async (params: Record) => { + const replyOptions = params.replyOptions as Record void | Promise>; + await replyOptions.onAssistantMessageStart?.(); + await replyOptions.onPartialReply?.({ text: "New session started" }); + await replyOptions.onAssistantMessageStart?.(); + await replyOptions.onBlockReply?.({ text: "New session started" }); + const delivery = params.delivery as { deliver?: (payload: unknown, info?: unknown) => Promise }; + await delivery.deliver?.({ text: "New session started" }, { kind: "final" }); + return { dispatchResult: { queuedFinal: true } }; + }); + const hostRuntime = { + channel: { + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + session: { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/sessions.json" }, + inbound: { + buildContext: (params: Record) => ({ + Body: "/new", + BodyForAgent: "/new", + From: "beeper", + RawBody: "/new", + SessionKey: (params.route as { routeSessionKey?: string }).routeSessionKey, + To: "beeper", + }), + dispatchReply, + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + }; + setBeeperChannelRuntimeForHost(hostRuntime, createTestBeeperChannelRuntime(aiRunStreams)); + const transport = createOpenClawHostRuntimeAdapter(hostRuntime); + + const done = (async () => { + for await (const event of transport.events()) { + if (event.event === "run.completed") break; + } + })(); + await transport.sendMessage({ + sessionKey: "agent:main:beeper:room", + message: "/new", + matrix: { + accountId: "@batuhan:beeper.com", + command: { name: "new" }, + roomId: "!room:example", + sender: "@alice:example", + }, + }); + await done; + + expect(startedAndAppendedParts(aiRunStreams).filter((part) => part.kind === "text").map((part) => part.text)).toEqual([ + "New session started", + ]); + setBeeperChannelRuntimeForHost(hostRuntime, undefined); + }); + + it("streams assistant agent events when reply callbacks only deliver the final block", async () => { + const aiRunStreams = createTestBeeperAIRunStreams(); + let agentEventListener: ((event: { data?: Record; runId?: string; sessionKey?: string; stream?: string }) => void) | undefined; + const dispatchReply = vi.fn(async (params: Record) => { + const replyOptions = params.replyOptions as { runId?: string }; + const sessionKey = params.routeSessionKey as string; + agentEventListener?.({ data: { text: "Working..." }, runId: replyOptions.runId, stream: "assistant" }); + agentEventListener?.({ data: { name: "search", phase: "running", text: "Searching docs", type: "tool.progress" }, runId: replyOptions.runId, stream: "tool.progress" }); + agentEventListener?.({ data: { delta: "hel", text: "hel" }, runId: replyOptions.runId, stream: "assistant" }); + agentEventListener?.({ data: { delta: "lo", text: "hello" }, sessionKey, stream: "assistant" }); + agentEventListener?.({ data: { itemId: "user-message", phase: "start", type: "userMessage" }, runId: replyOptions.runId, stream: "codex_app_server.item" }); + agentEventListener?.({ data: { itemId: "agent-message", phase: "start", type: "agentMessage" }, runId: replyOptions.runId, stream: "codex_app_server.item" }); + agentEventListener?.({ data: { itemId: "codex-tool", phase: "start", type: "tool_call" }, runId: replyOptions.runId, stream: "codex_app_server.item" }); + agentEventListener?.({ data: { itemId: "tool-c", phase: "update", kind: "tool", progressText: "loading", status: "running", name: "search" }, runId: replyOptions.runId, stream: "item" }); + agentEventListener?.({ data: { itemId: "codex-tool", phase: "finished", type: "tool_call" }, runId: replyOptions.runId, stream: "codex_app_server.item" }); + agentEventListener?.({ data: { args: { query: "docs" }, name: "search", phase: "start", toolCallId: "tool-stream" }, runId: replyOptions.runId, stream: "tool" }); + agentEventListener?.({ data: { name: "search", phase: "result", result: "found docs", toolCallId: "tool-stream" }, runId: replyOptions.runId, stream: "tool" }); + agentEventListener?.({ + data: { + metadata: { source: "codex" }, + description: "Searches OpenClaw docs", + phase: "start", + providerExecuted: true, + startedAtMs: 123, + title: "Search docs", + toolCall: { arguments: "{\"query\":\"openclaw\"}", id: "nested-tool", name: "search" }, + }, + runId: replyOptions.runId, + stream: "tool", + }); + agentEventListener?.({ + data: { delta: "raw reasoning delta" }, + runId: replyOptions.runId, + stream: "reasoning", + }); + agentEventListener?.({ + data: { + method: "rawResponseItem/completed", + item: { + type: "reasoning", + summary: [{ type: "summary_text", text: "raw checked" }], + content: [{ type: "reasoning_text", text: "raw thought" }], + }, + }, + runId: replyOptions.runId, + stream: "raw", + }); + agentEventListener?.({ + data: { + item: { + type: "reasoning", + summary: ["typed checked"], + content: ["typed thought"], + }, + method: "item/completed", + }, + runId: replyOptions.runId, + stream: "item", + }); + agentEventListener?.({ + data: { + call: { id: "nested-tool", name: "search" }, + completedAtMs: 456, + phase: "result", + providerExecuted: true, + result: { items: [{ title: "OpenClaw", url: "https://example.com/openclaw" }] }, + }, + runId: replyOptions.runId, + stream: "tool", + }); + agentEventListener?.({ data: { name: "bash", phase: "start", toolCallId: "delta-tool" }, runId: replyOptions.runId, stream: "tool" }); + agentEventListener?.({ data: { delta: "{\"cmd\":\"pwd\"}", name: "bash", phase: "input_delta", toolCallId: "delta-tool" }, runId: replyOptions.runId, stream: "tool" }); + agentEventListener?.({ data: { name: "bash", output: "/tmp/project", phase: "finished", toolCallId: "delta-tool" }, runId: replyOptions.runId, stream: "tool" }); + agentEventListener?.({ + data: { + name: "bash", + phase: "finished", + response: "tool wrapper response", + result: { + content: "wrapper content", + details: { + aggregated: "stdout\nstderr", + command: "npm test", + cwd: "/tmp/project", + durationMs: 25, + exitCode: 0, + status: "completed", + stderr: "stderr", + stdout: "stdout", + }, + }, + title: "Run tests", + toolCallId: "bash-rich", + }, + runId: replyOptions.runId, + stream: "tool", + }); + agentEventListener?.({ data: { phase: "update", title: "Plan", explanation: "checking docs", steps: ["Search", "Answer"] }, runId: replyOptions.runId, stream: "plan" }); + agentEventListener?.({ data: { itemId: "cmd-1", phase: "delta", title: "Shell", toolCallId: "cmd-1", name: "shell", output: "stdout" }, runId: replyOptions.runId, stream: "command_output" }); + agentEventListener?.({ + data: { + input: { + command: "/bin/zsh -lc \"date '+%Y-%m-%d %H:%M:%S %Z'\"", + cwd: "/Users/batuhan/.openclaw/workspace", + }, + name: "bash", + output: { status: "completed" }, + phase: "finished", + response: "2026-06-02 03:15:00 CEST", + status: "completed", + toolCallId: "cmd-date", + }, + runId: replyOptions.runId, + stream: "command_output", + }); + agentEventListener?.({ data: { itemId: "patch-1", phase: "end", title: "Patch", toolCallId: "patch-1", name: "patch", added: [], modified: ["a.ts"], deleted: [], summary: "changed a.ts" }, runId: replyOptions.runId, stream: "patch" }); + agentEventListener?.({ data: { items: [{ title: "Docs", url: "https://example.com" }] }, runId: replyOptions.runId, stream: "source" }); + agentEventListener?.({ data: { filename: "report.txt", id: "file_1" }, runId: replyOptions.runId, stream: "file" }); + agentEventListener?.({ data: { status: "indexed" }, runId: replyOptions.runId, stream: "data" }); + agentEventListener?.({ data: { phase: "retrieval" }, runId: replyOptions.runId, stream: "snapshot" }); + const delivery = params.delivery as { deliver?: (payload: unknown, info?: unknown) => Promise }; + await delivery.deliver?.({ text: "hello world" }, { kind: "final" }); + return { dispatchResult: { queuedFinal: true } }; + }); + const hostRuntime = { + channel: { + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + session: { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/sessions.json" }, + inbound: { + buildContext: (params: Record) => ({ + Body: "from Beeper", + BodyForAgent: "from Beeper", + From: "beeper", + RawBody: "from Beeper", + SessionKey: (params.route as { routeSessionKey?: string }).routeSessionKey, + To: "beeper", + }), + dispatchReply, + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + events: { + onAgentEvent: (listener) => { + agentEventListener = listener; + return () => { + agentEventListener = undefined; + }; + }, + }, + }; + setBeeperChannelRuntimeForHost(hostRuntime, createTestBeeperChannelRuntime(aiRunStreams)); + const transport = createOpenClawHostRuntimeAdapter(hostRuntime); + + const done = (async () => { + for await (const event of transport.events()) { + if (event.event === "run.completed") break; + } + })(); + await transport.sendMessage({ + sessionKey: "agent:main:beeper:room", + message: "from Beeper", + matrix: { accountId: "@batuhan:beeper.com", roomId: "!room:example", sender: "@alice:example" }, + }); + await done; + + const parts = startedAndAppendedParts(aiRunStreams); + expect(parts.filter((part) => part.kind === "text").map((part) => part.text)).toEqual([ + "hel", + "lo", + " world", + ]); + expect(parts.filter((part) => part.kind === "reasoning").map((part) => part.text)).toEqual([ + "raw reasoning delta", + "raw checked\n\nraw thought", + "typed checked\n\ntyped thought", + ]); + expect(parts).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "tool_result", toolCallId: "codex-tool", toolName: "tool" }), + expect.objectContaining({ activityType: "tool.progress", content: expect.objectContaining({ label: "search", phase: "running", text: "Searching docs" }), kind: "activity" }), + expect.objectContaining({ kind: "tool_start", toolCallId: "tool-stream", toolName: "search" }), + expect.objectContaining({ input: { query: "openclaw" }, kind: "tool_start", metadata: { description: "Searches OpenClaw docs", displayName: "Search docs", source: "codex" }, providerExecuted: true, startedAtMs: 123, title: "Search docs", toolCallId: "nested-tool", toolName: "search" }), + expect.objectContaining({ completedAtMs: 456, kind: "tool_result", output: { items: [{ title: "OpenClaw", url: "https://example.com/openclaw" }] }, providerExecuted: true, toolCallId: "nested-tool", toolName: "search" }), + expect.objectContaining({ kind: "custom", name: "com.beeper.source", value: expect.objectContaining({ sourceId: "https://example.com/openclaw", title: "OpenClaw", url: "https://example.com/openclaw" }) }), + expect.objectContaining({ delta: "{\"cmd\":\"pwd\"}", kind: "tool_input", toolCallId: "delta-tool" }), + expect.objectContaining({ kind: "tool_result", output: "/tmp/project", toolCallId: "delta-tool", toolName: "bash" }), + expect.objectContaining({ + command: "npm test", + details: { + aggregated: "stdout\nstderr", + command: "npm test", + cwd: "/tmp/project", + durationMs: 25, + exitCode: 0, + status: "completed", + stderr: "stderr", + stdout: "stdout", + }, + exitCode: 0, + kind: "tool_result", + metadata: { displayName: "Run tests" }, + output: { + content: "wrapper content", + details: { + aggregated: "stdout\nstderr", + command: "npm test", + cwd: "/tmp/project", + durationMs: 25, + exitCode: 0, + status: "completed", + stderr: "stderr", + stdout: "stdout", + }, + }, + response: "tool wrapper response", + result: { + content: "wrapper content", + details: { + aggregated: "stdout\nstderr", + command: "npm test", + cwd: "/tmp/project", + durationMs: 25, + exitCode: 0, + status: "completed", + stderr: "stderr", + stdout: "stdout", + }, + }, + status: "completed", + title: "Run tests", + toolCallId: "bash-rich", + toolName: "bash", + }), + expect.objectContaining({ kind: "tool_result", output: "loading", preliminary: true, toolCallId: "tool-c", toolName: "search" }), + expect.objectContaining({ kind: "tool_result", output: "checking docs", preliminary: true, toolCallId: "plan", toolName: "plan" }), + expect.objectContaining({ kind: "tool_result", output: "stdout", preliminary: true, toolCallId: "cmd-1", toolName: "shell" }), + expect.objectContaining({ kind: "tool_result", state: "complete", text: "{}", toolCallId: "tool-c", toolName: "search" }), + expect.objectContaining({ kind: "tool_result", state: "complete", text: "{}", toolCallId: "plan", toolName: "plan" }), + expect.objectContaining({ kind: "tool_result", state: "complete", text: "{}", toolCallId: "cmd-1", toolName: "shell" }), + expect.objectContaining({ + input: { + command: "/bin/zsh -lc \"date '+%Y-%m-%d %H:%M:%S %Z'\"", + cwd: "/Users/batuhan/.openclaw/workspace", + }, + kind: "tool_result", + command: "/bin/zsh -lc \"date '+%Y-%m-%d %H:%M:%S %Z'\"", + cwd: "/Users/batuhan/.openclaw/workspace", + output: { status: "completed" }, + response: "2026-06-02 03:15:00 CEST", + status: "completed", + toolCallId: "cmd-date", + toolName: "bash", + }), + expect.objectContaining({ kind: "tool_result", output: "changed a.ts", toolCallId: "patch-1", toolName: "patch" }), + expect.objectContaining({ kind: "custom", name: "com.beeper.source", value: expect.objectContaining({ sourceId: "https://example.com", title: "Docs", url: "https://example.com" }) }), + expect.objectContaining({ kind: "custom", name: "com.beeper.file", value: { id: "file_1", title: "report.txt" } }), + expect.objectContaining({ kind: "custom", name: "com.beeper.data", value: { name: "openclaw.data", value: { status: "indexed" } } }), + expect.objectContaining({ kind: "state_snapshot", value: { phase: "retrieval" } }), + ])); + expect(parts).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ content: { state: "running", text: "Working..." }, kind: "activity" }), + expect.objectContaining({ toolCallId: "user-message", kind: "tool_start" }), + expect.objectContaining({ toolCallId: "agent-message", kind: "tool_start" }), + ])); + const indexOf = (match: (part: Record) => boolean) => parts.findIndex((part) => match(part as Record)); + expect(aiRunStreams.start.mock.invocationCallOrder[0]).toBeLessThan(aiRunStreams.appendPart.mock.invocationCallOrder[0]); + expect(indexOf((part) => part.kind === "tool_start" && part.toolCallId === "delta-tool")).toBeLessThan(indexOf((part) => part.kind === "tool_input" && part.toolCallId === "delta-tool")); + expect(indexOf((part) => part.kind === "tool_input" && part.toolCallId === "delta-tool")).toBeLessThan(indexOf((part) => part.kind === "tool_result" && part.toolCallId === "delta-tool")); + expect(indexOf((part) => part.kind === "tool_result" && part.toolCallId === "nested-tool")).toBeLessThan(indexOf((part) => part.kind === "custom" && part.name === "com.beeper.source" && (part.value as { sourceId?: string })?.sourceId === "https://example.com/openclaw")); + setBeeperChannelRuntimeForHost(hostRuntime, undefined); + }); + + it("loads plugin runtime history from the OpenClaw session transcript", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "pickle-openclaw-history-")); + const sessionFile = path.join(tmpDir, "session.jsonl"); + await fs.writeFile(sessionFile, [ + JSON.stringify({ message: { id: "u1", role: "user", content: [{ type: "text", text: "Hi" }] }, timestamp: 10 }), + JSON.stringify({ message: { id: "a1", role: "assistant", content: [{ type: "text", text: "Hello" }] }, timestamp: 20 }), + ].join("\n")); + const transport = createOpenClawHostRuntimeAdapter({ + agent: { + session: { + getSessionEntry: () => ({ + sessionFile, + sessionId: "session-1", + }), + }, + }, + }); + + await expect(transport.request("chat.history", { limit: 2, sessionKey: "agent:main:beeper:room" })).resolves.toEqual({ + messages: [ + { content: "Hi", id: "u1", messageSeq: 1, role: "user", timestamp: 10 }, + { content: "Hello", id: "a1", messageSeq: 2, role: "agent", timestamp: 20 }, + ], + }); + }); + + it("adapts plugin transcript lifecycle updates into runtime events", async () => { + let listener: ((update: { sessionKey?: string; messageSeq?: number }) => void) | undefined; + const transport = createOpenClawHostRuntimeAdapter({ + events: { + onSessionTranscriptUpdate: (next) => { + listener = next; + return () => { + listener = undefined; + }; + }, + }, + }); + + const received: OpenClawGatewayEvent[] = []; + const done = (async () => { + for await (const event of transport.events((candidate) => candidate.payload !== undefined)) { + received.push(event); + break; + } + })(); + listener?.({ messageSeq: 9, sessionKey: "agent:main:dashboard:one" }); + await done; + + expect(received).toEqual([{ + event: "session.transcript.update", + payload: { messageSeq: 9, sessionKey: "agent:main:dashboard:one" }, + seq: 9, + }]); + }); +}); + +function fakeTransport(responses: Record, events: OpenClawGatewayEvent[] = []): OpenClawRuntimeRequestSurface & { + request: ReturnType; +} { + return { + async *events(filter) { + for (const event of events) { + if (!filter || filter(event)) yield event; + } + }, + request: vi.fn(async (method: string) => responses[method]), + }; +} + +function createTestBeeperAIRuns() { + const snapshot = (runId: string, events: Record[] = []) => ({ + body: "...", + events, + finalAIMessage: {}, + initialAIMessage: {}, + metadata: {}, + messageId: runId, + runId, + threadId: runId, + }); + return { + appendEvent: vi.fn(async ({ event, runId }: { event: Record; runId: string }) => + snapshot(runId, [event])), + begin: vi.fn(async ({ runId, threadId }: { runId: string; threadId?: string }) => + snapshot(runId, [ + { runId, threadId: threadId ?? runId, type: "RUN_STARTED" }, + { messageId: runId, role: "assistant", type: "TEXT_MESSAGE_START" }, + ])), + delete: vi.fn(async () => undefined), + error: vi.fn(async ({ message, runId }: { message?: string; runId: string }) => + snapshot(runId, [{ message, runId, type: "RUN_ERROR" }])), + finish: vi.fn(async ({ finishReason, runId }: { finishReason?: string; runId: string }) => + snapshot(runId, [ + { messageId: runId, type: "TEXT_MESSAGE_END" }, + { finishReason: finishReason ?? "stop", runId, threadId: runId, type: "RUN_FINISHED" }, + ])), + }; +} + +function createTestBeeperAIRunStreams() { + const result = (runId: string, events: Record[] = []) => ({ + body: "...", + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + events, + finalAIMessage: {}, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: {}, + raw: {}, + replacementEventId: "$stream-final", + roomId: "!room:example", + runId, + threadId: runId, + }); + return { + appendEvent: vi.fn(async ({ event, runId }: { event: Record; runId: string }) => + result(runId, [event])), + appendPart: vi.fn(async ({ runId }: { runId: string }) => + result(runId)), + error: vi.fn(async ({ message, runId }: { message?: string; runId: string }) => + result(runId, [{ message, runId, type: "RUN_ERROR" }])), + finish: vi.fn(async ({ finishReason, runId }: { finishReason?: string; runId: string }) => + result(runId, [{ finishReason: finishReason ?? "stop", runId, threadId: runId, type: "RUN_FINISHED" }])), + start: vi.fn(async ({ runId }: { runId: string }) => + result(runId, [ + { runId, threadId: runId, type: "RUN_STARTED" }, + { messageId: `msg-${runId}`, role: "assistant", type: "TEXT_MESSAGE_START" }, + ])), + }; +} + +function createTestBeeperChannelRuntime(aiRunStreams: ReturnType) { + const bridge = { + createBeeperTurnStream: vi.fn((options) => new BeeperTurnStream({ + ...options, + client: { + beeper: { + aiRuns: createTestBeeperAIRuns(), + aiRunStreams, + }, + } as never, + })), + flushRemoteEvents: vi.fn(async () => undefined), + getPortalByMXID: vi.fn(() => ({ portalKey: { id: "conversation:one", receiver: "openclaw:plugin" } })), + queueRemoteEvent: vi.fn(), + }; + return new BeeperChannelRuntime({ + bridge: bridge as never, + getAgents: () => [{ + agentId: "main", + displayName: "Main", + ghostUserId: "@sh-openclaw_agent_main:example", + }], + getBindingByRoom: (roomId) => roomId === "!room:example" + ? { + agentId: "main", + createdAt: 1, + ghostUserId: "@sh-openclaw_agent_main:example", + id: "binding", + roomId, + sessionKey: "agent:main:beeper:room", + updatedAt: 1, + } + : undefined, + getBindingBySessionKey: (sessionKey) => sessionKey === "agent:main:beeper:room" + ? { + agentId: "main", + createdAt: 1, + ghostUserId: "@sh-openclaw_agent_main:example", + id: "binding", + roomId: "!room:example", + sessionKey, + updatedAt: 1, + } + : undefined, + login: { id: "openclaw:plugin" }, + userId: "@sh-openclaw-bot:example", + }); +} + +function startedAndAppendedParts(aiRunStreams: ReturnType) { + const startOptions = aiRunStreams.start.mock.calls[0]?.[0] as { initialParts?: Array> } | undefined; + return [ + ...(startOptions?.initialParts ?? []), + ...aiRunStreams.appendPart.mock.calls.map(([options]) => options), + ]; +} diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts new file mode 100644 index 0000000..ac1304a --- /dev/null +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -0,0 +1,2559 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { OpenClawAgentContact, OpenClawBridgeConfig } from "./types"; +import { agentContactFromOpenClawAgent } from "./rooms"; +import type { OpenClawApprovalResolvePayload } from "./approval"; +import { getBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; +import { + AGUIEventType, + createApprovalRunState, + mapOpenClawApprovalRequest, + mapOpenClawApprovalResponse, + mapOpenClawCustom, +} from "./beeper-turn-events"; +import type { AGUIEvent } from "./beeper-turn-events"; + +export type GatewayRequestOptions = { + expectFinal?: boolean; + timeoutMs?: number | null; +}; + +export type OpenClawGatewayEvent = { + event?: string; + payload?: unknown; + seq?: number; + stateVersion?: unknown; +}; + +export interface OpenClawRuntimeRequestSurface { + close?(): Promise | void; + events(filter?: (event: OpenClawGatewayEvent) => boolean): AsyncIterable; + request(method: string, params?: unknown, options?: GatewayRequestOptions): Promise; +} + +export interface OpenClawHostRuntime { + agent?: { + resolveAgentDir?: (config: unknown, agentId?: string) => string; + resolveAgentTimeoutMs?: (options: Record) => number; + session?: { + getSessionEntry?: (options: Record) => Record | undefined; + listSessionEntries?: (options?: Record) => Array<{ entry: Record; sessionKey: string }>; + resolveSessionFilePath?: (sessionId: string, entry?: Record, options?: Record) => string; + upsertSessionEntry?: (options: Record) => Promise | void; + }; + }; + channel?: { + inbound?: { + buildContext?: (params: Record) => Record; + dispatchReply?: (params: Record) => Promise; + }; + reply?: { + dispatchReplyWithBufferedBlockDispatcher?: (params: Record) => Promise; + }; + session?: { + recordInboundSession?: (params: Record) => Promise | void; + resolveStorePath?: (store?: string, options?: Record) => string; + }; + }; + call?: (method: string, params?: unknown, options?: GatewayRequestOptions) => Promise; + config?: { + current?: () => unknown; + }; + events?: OpenClawHostEvents; + request?: (method: string, params?: unknown, options?: GatewayRequestOptions) => Promise; + subscribe?: (filter?: (event: OpenClawGatewayEvent) => boolean) => AsyncIterable; +} + +export type OpenClawHostEvents = + | ((filter?: (event: OpenClawGatewayEvent) => boolean) => AsyncIterable) + | { + onAgentEvent?: (listener: (event: OpenClawAgentRuntimeEvent) => void) => () => void; + onSessionTranscriptUpdate?: (listener: (update: OpenClawSessionTranscriptUpdate) => void) => () => void; + }; + +export type OpenClawAgentRuntimeEvent = { + data?: Record; + runId?: string; + seq?: number; + ts?: number; + sessionKey?: string; + stream?: string; +}; + +export type OpenClawSessionTranscriptUpdate = { + sessionFile?: string; + sessionKey?: string; + message?: unknown; + messageId?: string; + messageSeq?: number; +}; + +export interface OpenClawSessionCreateOptions { + agentId: string; + key?: string; + label?: string; + message?: string; + model?: string; + parentSessionKey?: string; + reasoningLevel?: string; + task?: string; + verboseLevel?: string; +} + +export interface OpenClawSessionPatchOptions { + agentId: string; + key: string; + label?: string; + reasoningLevel?: string; + verboseLevel?: string; +} + +export interface OpenClawSessionSendOptions { + attachments?: unknown[]; + idempotencyKey?: string; + matrix?: OpenClawMatrixMessageMetadata; + message: string; + replyTo?: OpenClawReplyReference; + sessionKey: string; + thinking?: string; + timeoutMs?: number; +} + +export interface OpenClawMatrixAttachmentMetadata { + contentType?: unknown; + contentUri?: unknown; + duration?: unknown; + encryptedFile?: unknown; + filename?: unknown; + height?: unknown; + kind?: unknown; + size?: unknown; + width?: unknown; +} + +export interface OpenClawMatrixMessageMetadata { + accountId?: string; + attachments?: OpenClawMatrixAttachmentMetadata[]; + command?: { + args?: string; + name: string; + }; + formattedBody?: string; + mentions?: { + room?: boolean; + userIds?: string[]; + }; + relation?: { + key?: string; + kind?: "reply" | "thread" | "edit" | "reaction" | "reaction_remove" | "redaction" | "read_receipt" | "marked_unread"; + quote?: { + body?: string; + sender?: string; + }; + replyToEventId?: string; + receiptType?: string; + targetEventId?: string; + targetReactionId?: string; + targetRunId?: string; + targetSessionKey?: string; + threadRootEventId?: string; + unread?: boolean; + }; + roomId?: string; + sender?: string; + threadRootEventId?: string; +} + +export interface OpenClawReplyReference { + eventId: string; + roomId?: string; +} + +export interface OpenClawSessionRef { + agentId?: string; + key: string; + label?: string; + raw?: unknown; + sessionFile?: string; + sessionId?: string; +} + +export interface OpenClawRunRef { + raw?: unknown; + runId: string; + sessionKey: string; +} + +export interface OpenClawListedSession { + agentId?: string; + chatType?: string; + derivedTitle?: string; + displayName?: string; + key: string; + label?: string; + lastAccountId?: string; + lastChannel?: string; + lastMessagePreview?: string; + lastProvider?: string; + lastTo?: string; + origin?: Record; + provider?: string; + sessionId?: string; + updatedAt?: number | null; +} + +export interface OpenClawChatHistoryMessage { + content?: unknown; + id?: string; + messageSeq?: number; + role?: string; + [key: string]: unknown; +} + +export interface OpenClawSessionHistoryRuntime { + readonly config: OpenClawBridgeConfig; + listAgentContacts(): Promise; + listSessions(params?: Record): Promise; + loadHistory(sessionKey: string, limit?: number): Promise; +} + +export interface OpenClawSessionTurnRuntime extends OpenClawSessionHistoryRuntime { + createSession(options: OpenClawSessionCreateOptions): Promise; + patchSession(options: OpenClawSessionPatchOptions): Promise; + resolveApproval(payload: OpenClawApprovalResolvePayload): Promise; + sendMessage(options: OpenClawSessionSendOptions): Promise; +} + +export interface OpenClawBridgeRuntime extends OpenClawSessionTurnRuntime { + close(): Promise; +} + +export class OpenClawPluginRuntimeAdapter { + readonly config: OpenClawBridgeConfig; + readonly transport: OpenClawRuntimeRequestSurface; + + constructor(options: { config: OpenClawBridgeConfig; transport: OpenClawRuntimeRequestSurface }) { + this.config = options.config; + this.transport = options.transport; + } + + async listAgentContacts(): Promise { + const result = await this.transport.request("agents.list", {}); + const agents = arrayValue(recordValue(result)?.agents) ?? arrayValue(result); + return (agents ?? []).map((agent) => agentContactFromOpenClawAgent(this.config, recordValue(agent) ?? {})); + } + + async createSession(options: OpenClawSessionCreateOptions): Promise { + const raw = await this.transport.request("sessions.create", stripUndefined({ + agentId: options.agentId, + key: options.key, + label: options.label, + message: options.message, + model: options.model, + parentSessionKey: options.parentSessionKey, + task: options.task, + })); + const record = recordValue(raw) ?? {}; + const key = stringValue(record.key) ?? stringValue(record.sessionKey) ?? options.key; + if (!key) throw new Error("OpenClaw sessions.create did not return a session key"); + if (options.reasoningLevel || options.verboseLevel) { + const patch: OpenClawSessionPatchOptions = { + agentId: options.agentId, + key, + }; + if (options.reasoningLevel) patch.reasoningLevel = options.reasoningLevel; + if (options.verboseLevel) patch.verboseLevel = options.verboseLevel; + await this.patchSession(patch); + } + return stripUndefined({ + agentId: stringValue(record.agentId) ?? options.agentId, + key, + label: stringValue(record.label) ?? options.label, + raw, + sessionId: stringValue(record.sessionId), + }); + } + + async patchSession(options: OpenClawSessionPatchOptions): Promise { + await this.transport.request("sessions.patch", stripUndefined({ + agentId: options.agentId, + key: options.key, + label: options.label, + reasoningLevel: options.reasoningLevel, + verboseLevel: options.verboseLevel, + })); + } + + async listSessions(params: Record = {}): Promise { + const raw = await this.transport.request("sessions.list", params); + const sessions = arrayValue(recordValue(raw)?.sessions) ?? []; + return sessions.flatMap((session) => { + const record = recordValue(session); + const key = stringValue(record?.key); + if (!record || !key) return []; + return [stripUndefined({ + agentId: stringValue(record.agentId), + chatType: stringValue(record.chatType), + derivedTitle: stringValue(record.derivedTitle), + displayName: stringValue(record.displayName), + key, + label: stringValue(record.label), + lastAccountId: stringValue(record.lastAccountId), + lastChannel: stringValue(record.lastChannel), + lastMessagePreview: stringValue(record.lastMessagePreview), + lastProvider: stringValue(record.lastProvider), + lastTo: stringValue(record.lastTo), + origin: recordValue(record.origin), + provider: stringValue(record.provider), + sessionFile: stringValue(record.sessionFile), + sessionId: stringValue(record.sessionId), + updatedAt: typeof record.updatedAt === "number" || record.updatedAt === null ? record.updatedAt : undefined, + })]; + }); + } + + async loadHistory(sessionKey: string, limit?: number): Promise { + const raw = await this.transport.request("chat.history", { + sessionKey, + ...(limit !== undefined ? { limit } : {}), + }); + const messages = arrayValue(recordValue(raw)?.messages) ?? []; + return messages.flatMap((message) => { + const record = recordValue(message); + if (!record) return []; + const normalized: OpenClawChatHistoryMessage = { ...record }; + const role = stringValue(record.role); + const id = stringValue(record.id); + if (role) normalized.role = role; + if (id) normalized.id = id; + return [normalized]; + }); + } + + async sendMessage(options: OpenClawSessionSendOptions): Promise { + const requestOptions: GatewayRequestOptions = { expectFinal: false }; + if (options.timeoutMs !== undefined) requestOptions.timeoutMs = options.timeoutMs; + if (this.transport instanceof OpenClawHostRuntimeAdapter) { + return this.transport.sendMessage(options, requestOptions); + } + throw new Error("OpenClaw Beeper turns require OpenClaw channel inbound helpers"); + } + + async resolveApproval(payload: OpenClawApprovalResolvePayload): Promise { + const { approvalKind, ...requestPayload } = payload; + const method = approvalKind === "plugin" ? "plugin.approval.resolve" : "exec.approval.resolve"; + return await this.transport.request(method, requestPayload); + } + + async close(): Promise { + await this.transport.close?.(); + } +} + +export class OpenClawHostRuntimeAdapter implements OpenClawRuntimeRequestSurface { + readonly #runtime: OpenClawHostRuntime; + readonly #localEvents = new LocalEventBus(); + + constructor(runtime: OpenClawHostRuntime) { + this.#runtime = runtime; + } + + request(method: string, params?: unknown, options?: GatewayRequestOptions): Promise { + if (isDirectPluginRuntimeMethod(method)) { + return this.#pluginRuntimeRequest(method, params, options); + } + const call = this.#runtime.request ?? this.#runtime.call; + if (!call) return this.#pluginRuntimeRequest(method, params, options); + return call(method, params, options); + } + + events(filter?: (event: OpenClawGatewayEvent) => boolean): AsyncIterable { + if (typeof this.#runtime.events === "object" && this.#runtime.events?.onAgentEvent) { + return mergeEvents([ + agentRuntimeEvents(this.#runtime.events.onAgentEvent, filter), + this.#localEvents.events(filter), + ]); + } + if (typeof this.#runtime.events === "object" && this.#runtime.events?.onSessionTranscriptUpdate) { + return mergeEvents([ + transcriptUpdateEvents(this.#runtime.events.onSessionTranscriptUpdate, filter), + this.#localEvents.events(filter), + ]); + } + const events = (typeof this.#runtime.events === "function" ? this.#runtime.events : undefined) ?? this.#runtime.subscribe; + if (!events) return this.#localEvents.events(filter); + return events(filter); + } + + async sendMessage(options: OpenClawSessionSendOptions, requestOptions: GatewayRequestOptions = {}): Promise { + const raw = await sendSessionInPluginRuntime(this.#runtime, this.#localEvents, { + key: options.sessionKey, + message: options.message, + ...(options.attachments ? { attachments: options.attachments } : {}), + ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}), + ...(options.matrix ? { matrix: options.matrix } : {}), + ...(options.replyTo ? { replyTo: options.replyTo } : {}), + ...(options.thinking ? { thinking: options.thinking } : {}), + ...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}), + }, requestOptions); + const record = recordValue(raw) ?? {}; + const runId = stringValue(record.runId); + if (!runId) throw new Error("OpenClaw channel inbound turn did not return a runId"); + return { raw, runId, sessionKey: stringValue(record.sessionKey) ?? options.sessionKey }; + } + + async #pluginRuntimeRequest( + method: string, + params?: unknown, + _options?: GatewayRequestOptions + ): Promise { + switch (method) { + case "agents.list": + return { agents: agentsFromPluginConfig(this.#runtime.config?.current?.()) } as T; + case "chat.history": + return { messages: await historyFromPluginRuntime(this.#runtime, params) } as T; + case "sessions.create": + return await createSessionInPluginRuntime(this.#runtime, params) as T; + case "sessions.list": + return { sessions: sessionsFromPluginRuntime(this.#runtime, params) } as T; + case "sessions.patch": + return await patchSessionInPluginRuntime(this.#runtime, params) as T; + default: + throw new Error(`OpenClaw plugin runtime does not expose request/call for ${method}`); + } + } +} + +export function createOpenClawHostRuntimeAdapter(runtime: OpenClawHostRuntime): OpenClawHostRuntimeAdapter { + return new OpenClawHostRuntimeAdapter(runtime); +} + +function isDirectPluginRuntimeMethod(method: string): boolean { + return method === "agents.list" + || method === "chat.history" + || method === "sessions.create" + || method === "sessions.patch" + || method === "sessions.list"; +} + +function arrayValue(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + +function recordValue(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +async function* emptyEvents(): AsyncIterable {} + +class LocalEventBus { + readonly #subscribers = new Set<(event: OpenClawGatewayEvent) => void>(); + + emit(event: OpenClawGatewayEvent): void { + for (const subscriber of this.#subscribers) subscriber(event); + } + + async *events(filter?: (event: OpenClawGatewayEvent) => boolean): AsyncIterable { + const queue: OpenClawGatewayEvent[] = []; + let notify: (() => void) | undefined; + let closed = false; + const subscriber = (event: OpenClawGatewayEvent) => { + if (filter && !filter(event)) return; + queue.push(event); + notify?.(); + notify = undefined; + }; + this.#subscribers.add(subscriber); + try { + for (;;) { + const event = queue.shift(); + if (event) { + yield event; + continue; + } + if (closed) return; + await new Promise((resolve) => { + notify = resolve; + }); + } + } finally { + closed = true; + this.#subscribers.delete(subscriber); + notify?.(); + } + } +} + +async function* mergeEvents(iterables: AsyncIterable[]): AsyncIterable { + const queue: OpenClawGatewayEvent[] = []; + let notify: (() => void) | undefined; + let closed = false; + const controllers = iterables.map(() => new AbortController()); + const pump = (async () => { + await Promise.all(iterables.map(async (iterable, index) => { + try { + for await (const event of iterable) { + if (controllers[index]?.signal.aborted) return; + queue.push(event); + notify?.(); + notify = undefined; + } + } catch { + // Individual event surfaces are best effort. The bridge keeps any other + // live source open so streaming does not die on optional host hooks. + } + })); + })(); + try { + for (;;) { + const event = queue.shift(); + if (event) { + yield event; + continue; + } + if (closed) return; + await Promise.race([ + new Promise((resolve) => { + notify = resolve; + }), + pump.then(() => undefined), + ]); + if (queue.length === 0) return; + } + } finally { + closed = true; + for (const controller of controllers) controller.abort(); + notify?.(); + } +} + +async function* agentRuntimeEvents( + onAgentEvent: (listener: (event: OpenClawAgentRuntimeEvent) => void) => () => void, + filter?: (event: OpenClawGatewayEvent) => boolean, +): AsyncIterable { + const queue: OpenClawGatewayEvent[] = []; + let notify: (() => void) | undefined; + let closed = false; + const unsubscribe = onAgentEvent((agentEvent) => { + const data = recordValue(agentEvent.data) ?? {}; + const event = stripUndefined({ + event: agentEvent.stream, + payload: stripUndefined({ + ...data, + ...(agentEvent.sessionKey ? { sessionKey: agentEvent.sessionKey } : {}), + }), + seq: numberValue(data.seq), + }); + if (filter && !filter(event)) return; + queue.push(event); + notify?.(); + notify = undefined; + }); + try { + for (;;) { + const event = queue.shift(); + if (event) { + yield event; + continue; + } + if (closed) return; + await new Promise((resolve) => { + notify = resolve; + }); + } + } finally { + closed = true; + unsubscribe(); + notify?.(); + } +} + +async function* transcriptUpdateEvents( + onSessionTranscriptUpdate: (listener: (update: OpenClawSessionTranscriptUpdate) => void) => () => void, + filter?: (event: OpenClawGatewayEvent) => boolean, +): AsyncIterable { + const queue: OpenClawGatewayEvent[] = []; + let notify: (() => void) | undefined; + let closed = false; + const unsubscribe = onSessionTranscriptUpdate((update) => { + const event = stripUndefined({ + event: "session.transcript.update", + payload: update, + seq: update.messageSeq, + }); + if (filter && !filter(event)) return; + queue.push(event); + notify?.(); + notify = undefined; + }); + try { + for (;;) { + const event = queue.shift(); + if (event) { + yield event; + continue; + } + if (closed) return; + await new Promise((resolve) => { + notify = resolve; + }); + } + } finally { + closed = true; + unsubscribe(); + notify?.(); + } +} + +function agentsFromPluginConfig(config: unknown): Array> { + const agents = recordValue(recordValue(config)?.agents); + const configured = arrayValue(agents?.list) + ?? arrayValue(agents?.agents) + ?? arrayValue(agents?.items); + const normalized = (configured ?? []).flatMap((agent) => { + const record = recordValue(agent); + if (!record) return []; + const id = stringValue(record.id) ?? stringValue(record.agentId) ?? stringValue(record.name); + if (!id) return []; + return [stripUndefined({ + id, + displayName: stringValue(record.displayName) ?? stringValue(record.name) ?? id, + description: stringValue(record.description), + })]; + }); + return normalized.length > 0 ? normalized : [{ id: "main", displayName: "main" }]; +} + +function sessionsFromPluginRuntime(runtime: OpenClawHostRuntime, params: unknown): Array> { + const listSessionEntries = runtime.agent?.session?.listSessionEntries; + if (!listSessionEntries) return []; + const sessionEntriesByKey = new Map; sessionKey: string }>(); + for (const item of listSessionEntries() ?? []) { + const entry = recordValue(item.entry); + const sessionKey = stringValue(item.sessionKey) ?? stringValue(entry?.sessionKey) ?? stringValue(entry?.key); + if (entry && sessionKey) sessionEntriesByKey.set(sessionKey, { entry, sessionKey }); + } + for (const agentId of agentIdsFromPluginConfig(runtime.config?.current?.())) { + for (const item of listSessionEntries({ agentId }) ?? []) { + const entry = recordValue(item.entry); + const sessionKey = stringValue(item.sessionKey) ?? stringValue(entry?.sessionKey) ?? stringValue(entry?.key); + if (entry && sessionKey) sessionEntriesByKey.set(sessionKey, { entry, sessionKey }); + } + } + const sessionEntries = [...sessionEntriesByKey.values()]; + const includeArchived = recordValue(params)?.includeArchived === true; + return sessionEntries.flatMap((item) => { + const entry = recordValue(item.entry); + const sessionKey = stringValue(item.sessionKey) ?? stringValue(entry?.sessionKey) ?? stringValue(entry?.key); + if (!entry || !sessionKey) return []; + if (!includeArchived && entry.archived === true) return []; + const origin = recordValue(entry.origin); + return [stripUndefined({ + agentId: stringValue(entry.agentId) ?? agentIdFromSessionKey(sessionKey), + chatType: stringValue(entry.chatType) ?? stringValue(origin?.chatType), + displayName: stringValue(entry.displayName) ?? stringValue(entry.title) ?? stringValue(entry.label) ?? stringValue(entry.derivedTitle) ?? sessionKey, + derivedTitle: stringValue(entry.derivedTitle), + key: sessionKey, + label: stringValue(entry.label), + lastAccountId: stringValue(entry.lastAccountId) ?? stringValue(origin?.accountId), + lastChannel: stringValue(entry.lastChannel) ?? stringValue(origin?.provider) ?? stringValue(origin?.surface), + lastProvider: stringValue(entry.lastProvider) ?? stringValue(origin?.provider), + lastTo: stringValue(entry.lastTo) ?? stringValue(origin?.to), + origin, + provider: stringValue(entry.provider) ?? stringValue(origin?.provider), + sessionFile: stringValue(entry.sessionFile), + sessionId: stringValue(entry.sessionId), + updatedAt: typeof entry.updatedAt === "number" || entry.updatedAt === null ? entry.updatedAt : undefined, + })]; + }); +} + +async function createSessionInPluginRuntime(runtime: OpenClawHostRuntime, params: unknown): Promise> { + const record = recordValue(params) ?? {}; + const agentId = stringValue(record.agentId) ?? "main"; + const label = stringValue(record.label); + const sessionKey = stringValue(record.key) ?? buildPluginSessionKey(agentId, label); + const entry = resolvePluginSession(runtime, sessionKey, agentId).entry ?? {}; + const sessionId = stringValue(entry.sessionId) ?? sessionIdFromSessionKey(sessionKey); + const now = Date.now(); + const next = stripUndefined({ + ...entry, + chatType: stringValue(entry.chatType) ?? "direct", + derivedTitle: stringValue(entry.derivedTitle) ?? label, + label: label ?? stringValue(entry.label), + origin: recordValue(entry.origin) ?? { provider: "beeper", surface: "beeper", chatType: "direct" }, + provider: stringValue(entry.provider) ?? "beeper", + reasoningLevel: stringValue(record.reasoningLevel) ?? stringValue(entry.reasoningLevel), + sessionFile: stringValue(entry.sessionFile) ?? resolvePluginSessionFile(runtime, agentId, sessionId, entry), + sessionId, + updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : now, + verboseLevel: stringValue(record.verboseLevel) ?? stringValue(entry.verboseLevel), + }); + await runtime.agent?.session?.upsertSessionEntry?.({ agentId, entry: next, sessionKey }); + return { agentId, key: sessionKey, label, sessionFile: next.sessionFile, sessionId }; +} + +async function patchSessionInPluginRuntime(runtime: OpenClawHostRuntime, params: unknown): Promise> { + const record = recordValue(params) ?? {}; + const sessionKey = stringValue(record.key) ?? stringValue(record.sessionKey); + if (!sessionKey) throw new Error("OpenClaw sessions.patch requires session key"); + const agentId = stringValue(record.agentId) ?? agentIdFromSessionKey(sessionKey) ?? "main"; + const resolved = resolvePluginSession(runtime, sessionKey, agentId); + const entry = resolved.entry ?? {}; + const next = stripUndefined({ + ...entry, + ...(record.label !== undefined ? { label: stringValue(record.label) } : {}), + ...(record.reasoningLevel !== undefined ? { reasoningLevel: stringValue(record.reasoningLevel) } : {}), + ...(record.verboseLevel !== undefined ? { verboseLevel: stringValue(record.verboseLevel) } : {}), + updatedAt: Date.now(), + }); + await runtime.agent?.session?.upsertSessionEntry?.({ agentId, entry: next, sessionKey }); + return { agentId, entry: next, key: sessionKey, ok: true }; +} + +async function sendSessionInPluginRuntime( + runtime: OpenClawHostRuntime, + localEvents: LocalEventBus, + params: unknown, + options?: GatewayRequestOptions, +): Promise> { + const record = recordValue(params) ?? {}; + const sessionKey = stringValue(record.key) ?? stringValue(record.sessionKey); + const message = stringValue(record.message); + if (!sessionKey) throw new Error("OpenClaw channel inbound turn requires session key"); + if (!message) throw new Error("OpenClaw channel inbound turn requires message"); + const agentId = agentIdFromSessionKey(sessionKey) ?? "main"; + const resolved = resolvePluginSession(runtime, sessionKey, agentId); + const entry = resolved.entry ?? {}; + const sessionId = stringValue(entry.sessionId) ?? sessionIdFromSessionKey(sessionKey); + const sessionFile = stringValue(entry.sessionFile) ?? resolvePluginSessionFile(runtime, agentId, sessionId, entry); + const runId = `beeper:${randomUUID()}`; + const cfg = runtime.config?.current?.(); + if (!canRunNativeChannelTurn(runtime)) { + throw new Error("OpenClaw Beeper requires OpenClaw channel inbound helpers (runtime.channel.inbound, runtime.channel.reply, and runtime.channel.session)"); + } + const timeoutMs = options?.timeoutMs ?? numberValue(record.timeoutMs) ?? runtime.agent?.resolveAgentTimeoutMs?.({ cfg }) ?? 48 * 60 * 60 * 1000; + startPluginRun(localEvents, { + agentId, + runId, + sessionId, + sessionKey, + }, () => + runBeeperChannelTurnInPluginRuntime({ + agentId, + cfg, + localEvents, + message, + record, + runId, + runtime, + sessionFile, + sessionId, + sessionKey, + timeoutMs, + }) + ); + return { runId, sessionFile, sessionId, sessionKey }; +} + +function startPluginRun( + localEvents: LocalEventBus, + base: { agentId: string; runId: string; sessionId: string; sessionKey: string }, + run: () => Promise, +): void { + localEvents.emit({ event: "run.queued", payload: base }); + void run().catch((error) => { + localEvents.emit({ + event: "run.failed", + payload: { + ...base, + error: errorText(error), + }, + }); + }); +} + +function canRunNativeChannelTurn(runtime: OpenClawHostRuntime): boolean { + return Boolean( + runtime.channel?.inbound?.buildContext && + runtime.channel.inbound.dispatchReply && + runtime.channel.session?.recordInboundSession && + runtime.channel.reply?.dispatchReplyWithBufferedBlockDispatcher, + ); +} + +async function runBeeperChannelTurnInPluginRuntime(params: { + agentId: string; + cfg: unknown; + localEvents: LocalEventBus; + message: string; + record: Record; + runId: string; + runtime: OpenClawHostRuntime; + sessionFile: string; + sessionId: string; + sessionKey: string; + timeoutMs: number; +}): Promise { + const inbound = params.runtime.channel?.inbound; + const channelSession = params.runtime.channel?.session; + const channelReply = params.runtime.channel?.reply; + if (!inbound?.buildContext || !inbound.dispatchReply || !channelSession?.recordInboundSession || !channelReply?.dispatchReplyWithBufferedBlockDispatcher) { + throw new Error("OpenClaw plugin runtime channel inbound helpers are incomplete"); + } + + const sender = recordValue(recordValue(params.record.matrix)?.sender) ?? {}; + const matrix = recordValue(params.record.matrix) ?? {}; + const accountId = stringValue(matrix.accountId); + if (!accountId) throw new Error("OpenClaw Beeper inbound turns require matrix.accountId."); + const senderId = stringValue(matrix.sender) ?? stringValue(sender.id) ?? "beeper"; + const command = recordValue(matrix.command); + const commandName = stringValue(command?.name); + const commandArgs = stringValue(command?.args) ?? ""; + const commandBody = commandName ? `/${commandName}${commandArgs ? ` ${commandArgs}` : ""}` : params.message; + const roomId = stringValue(recordValue(params.record.matrix)?.roomId) ?? stringValue(params.record.roomId) ?? params.sessionKey; + const eventId = stringValue(params.record.idempotencyKey) ?? params.runId; + const sessionConfig = recordValue(recordValue(params.cfg)?.session); + const storePath = channelSession.resolveStorePath?.(stringValue(sessionConfig?.store), { agentId: params.agentId }) + ?? path.dirname(params.sessionFile); + const ctxPayload = inbound.buildContext({ + channel: "beeper", + accountId, + provider: "beeper", + surface: "beeper", + messageId: eventId, + timestamp: Date.now(), + from: senderId, + sender: { + id: senderId, + name: senderId, + displayLabel: senderId, + }, + conversation: { + kind: "direct", + id: roomId, + label: roomId, + routePeer: { + kind: "direct", + id: roomId, + }, + }, + route: { + agentId: params.agentId, + accountId, + routeSessionKey: params.sessionKey, + dispatchSessionKey: params.sessionKey, + createIfMissing: true, + }, + reply: { + to: roomId, + originatingTo: roomId, + nativeChannelId: roomId, + replyToId: stringValue(recordValue(matrix.relation)?.replyToEventId) ?? stringValue(recordValue(params.record.replyTo)?.eventId), + }, + message: { + body: params.message, + rawBody: params.message, + bodyForAgent: params.message, + commandBody, + envelopeFrom: senderId, + senderLabel: senderId, + preview: params.message.slice(0, 280), + }, + ...(commandName + ? { + command: { + authorized: true, + body: commandBody, + kind: "text-slash", + name: commandName, + }, + } + : {}), + access: { + commands: { + authorized: true, + allowTextCommands: true, + useAccessGroups: false, + authorizers: [{ configured: true, allowed: true }], + }, + dm: { + decision: "allow", + allowFrom: [], + }, + event: { + kind: "message", + authMode: "none", + mayPair: false, + authorized: true, + hasOriginSubject: true, + originSubjectMatched: true, + }, + }, + supplemental: relationSupplementalContext(matrix), + extra: { + OpenClawBeeperRunId: params.runId, + }, + }); + + const threadRoot = stringValue(recordValue(matrix.relation)?.threadRootEventId) ?? stringValue(recordValue(matrix.relation)?.replyToEventId); + const stream = createBeeperReplyStreamEmitter({ + agentId: params.agentId, + hostRuntime: params.runtime, + localEvents: params.localEvents, + roomId, + runId: params.runId, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + ...(threadRoot ? { threadRoot } : {}), + }); + params.localEvents.emit({ event: "run.started", payload: { agentId: params.agentId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + let unsubscribeAgentEvents: (() => void) | undefined; + let streamCallbackTail = Promise.resolve(); + const enqueueStream = (operation: () => Promise) => { + streamCallbackTail = streamCallbackTail + .catch(() => undefined) + .then(operation); + stream.trackExternal(streamCallbackTail); + return streamCallbackTail; + }; + const scheduleStream = (operation: () => Promise) => { + enqueueStream(operation); + }; + unsubscribeAgentEvents = forwardAgentRuntimeStreamEvents({ + enqueue: enqueueStream, + runId: params.runId, + runtime: params.runtime, + sessionKey: params.sessionKey, + stream, + }); + try { + await inbound.dispatchReply({ + cfg: params.cfg, + channel: "beeper", + accountId, + agentId: params.agentId, + routeSessionKey: params.sessionKey, + storePath, + ctxPayload, + recordInboundSession: channelSession.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: channelReply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { + deliver: async (payload: unknown, info?: unknown) => { + const final = stringValue(recordValue(info)?.kind) === "final"; + enqueueStream(() => stream.textPayload(payload, final ? "final" : "block")); + if (final) await stream.finish(); + return { visibleReplySent: true }; + }, + onError: async (error: unknown) => { + await stream.fail(error); + params.localEvents.emit({ event: "run.failed", payload: { agentId: params.agentId, error: errorText(error), runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + }, + }, + replyOptions: { + runId: params.runId, + disableBlockStreaming: false, + reasoningLevelOverride: "stream", + verboseLevelOverride: "full", + sourceReplyDeliveryMode: "automatic", + timeoutOverrideSeconds: Math.max(1, Math.ceil(params.timeoutMs / 1000)), + suppressDefaultToolProgressMessages: true, + allowProgressCallbacksWhenSourceDeliverySuppressed: true, + onAssistantMessageStart: stream.assistantMessageStart, + onBlockReply: (payload: unknown) => scheduleStream(() => stream.textPayload(payload, "block")), + onBlockReplyQueued: (payload: unknown) => scheduleStream(() => stream.textPayload(payload, "block")), + onPartialReply: (payload: unknown) => scheduleStream(() => stream.textPayload(payload, "partial")), + onReasoningEnd: () => scheduleStream(() => stream.reasoningEnd()), + onReasoningStream: (payload: unknown) => scheduleStream(() => stream.reasoningPayload(payload)), + onToolStart: (payload: unknown) => scheduleStream(() => stream.toolStart(payload)), + onToolResult: (payload: unknown) => scheduleStream(() => stream.toolResult(payload)), + onItemEvent: (payload: unknown) => scheduleStream(() => stream.itemEvent(payload)), + onPlanUpdate: (payload: unknown) => scheduleStream(() => stream.planUpdate(payload)), + onApprovalEvent: (payload: unknown) => scheduleStream(() => stream.approvalEvent(payload)), + onCommandOutput: (payload: unknown) => scheduleStream(() => stream.commandOutput(payload)), + onPatchSummary: (payload: unknown) => scheduleStream(() => stream.patchSummary(payload)), + onCompactionStart: () => scheduleStream(() => stream.itemEvent({ kind: "compaction", phase: "start", title: "Compacting context" })), + onCompactionEnd: () => scheduleStream(() => stream.itemEvent({ kind: "compaction", phase: "complete", title: "Compacted context" })), + }, + record: { + createIfMissing: true, + onRecordError: (error: unknown) => { + params.localEvents.emit({ event: "session.record.failed", payload: { agentId: params.agentId, error: errorText(error), runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + }, + updateLastRoute: { + sessionKey: params.sessionKey, + channel: "beeper", + to: roomId, + accountId, + }, + }, + messageId: eventId, + }); + await stream.finish(); + params.localEvents.emit({ event: "stream.finished", payload: { agentId: params.agentId, roomId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + params.localEvents.emit({ event: "run.completed", payload: { agentId: params.agentId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + } catch (error) { + await stream.fail(error); + params.localEvents.emit({ event: "run.failed", payload: { agentId: params.agentId, error: errorText(error), runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + } finally { + unsubscribeAgentEvents?.(); + } +} + +function forwardAgentRuntimeStreamEvents(params: { + enqueue: (operation: () => Promise) => Promise; + runId: string; + runtime: OpenClawHostRuntime; + sessionKey: string; + stream: ReturnType; +}): (() => void) | undefined { + const onAgentEvent = typeof params.runtime.events === "object" ? params.runtime.events?.onAgentEvent : undefined; + if (!onAgentEvent) { + params.stream.debug("openclaw_beeper_agent_event_subscription_missing", { + runId: params.runId, + sessionKey: params.sessionKey, + }); + return undefined; + } + params.stream.debug("openclaw_beeper_agent_event_subscription_started", { + runId: params.runId, + sessionKey: params.sessionKey, + }); + return onAgentEvent((event) => { + const data = recordValue(event.data) ?? {}; + const matched = matchesAgentStreamEvent({ data, event, runId: params.runId, sessionKey: params.sessionKey }); + const stream = normalizeAgentStream(event.stream) ?? stringValue(data.type); + params.stream.debug("openclaw_beeper_agent_event_seen", { + dataKeys: Object.keys(data).slice(0, 12), + eventRunId: stringValue(event.runId) ?? stringValue(data.runId) ?? stringValue(data.run_id), + eventSessionKey: stringValue(event.sessionKey) ?? stringValue(data.sessionKey) ?? stringValue(data.session_key), + matched, + stream: event.stream, + normalizedStream: stream, + }); + if (!matched) return; + const exposedReasoningText = exposedCodexReasoningText(stream, data); + if (exposedReasoningText) { + params.enqueue(() => params.stream.reasoningPayload({ + text: exposedReasoningText, + isReasoningSnapshot: true, + })); + return; + } + switch (stream) { + case "assistant": + params.enqueue(() => params.stream.textPayload(data, "partial")); + break; + case "run.progress": + case "tool.progress": + case "tool_progress": + params.enqueue(() => params.stream.activity({ + ...data, + activityType: stream, + text: toolProgressText(data), + })); + break; + case "lifecycle": + case "metadata": + case "model": + case "usage": + case "context": + params.enqueue(() => params.stream.lifecycleEvent(data)); + break; + case "thinking": + case "reasoning": + params.enqueue(() => params.stream.reasoningPayload(data)); + break; + case "tool": + if (stringValue(data.phase) === "start") { + params.enqueue(() => params.stream.toolStart(data)); + } else if (isToolInputDeltaPhase(stringValue(data.phase)) || stringValue(data.inputTextDelta) || stringValue(data.argsDelta) || stringValue(data.argumentsDelta)) { + params.enqueue(() => params.stream.toolInputDelta(data)); + } else if (stringValue(data.phase) === "result" || isCompletePhase(stringValue(data.phase))) { + params.enqueue(() => params.stream.toolResult(data)); + } else { + params.enqueue(() => params.stream.itemEvent({ + ...data, + kind: "tool", + progressText: stringValue(data.partialResult) ?? stringValue(data.output) ?? stringValue(data.result), + })); + } + break; + case "item": + params.enqueue(() => params.stream.itemEvent(data)); + break; + case "plan": + params.enqueue(() => params.stream.planUpdate(data)); + break; + case "approval": + params.enqueue(() => params.stream.approvalEvent(data)); + break; + case "command_output": + case "command-output": + params.enqueue(() => params.stream.commandOutput(data)); + break; + case "patch": + params.enqueue(() => params.stream.patchSummary(data)); + break; + case "state": + case "snapshot": + params.enqueue(() => params.stream.stateSnapshot(data)); + break; + case "source": + case "sources": + params.enqueue(() => params.stream.customData("source", data)); + break; + case "file": + case "files": + case "document": + case "documents": + params.enqueue(() => params.stream.customData(stream, data)); + break; + case "data": + params.enqueue(() => params.stream.customData("data", data)); + break; + case "raw": + params.enqueue(() => params.stream.raw(stream, data)); + break; + default: + break; + } + }); +} + +function matchesAgentStreamEvent(params: { + data: Record; + event: OpenClawAgentRuntimeEvent; + runId: string; + sessionKey: string; +}): boolean { + const eventRunId = stringValue(params.event.runId) ?? stringValue(params.data.runId) ?? stringValue(params.data.run_id); + if (eventRunId) return eventRunId === params.runId; + const eventSessionKey = stringValue(params.event.sessionKey) ?? stringValue(params.data.sessionKey) ?? stringValue(params.data.session_key); + return eventSessionKey === params.sessionKey; +} + +function normalizeAgentStream(stream: string | undefined): string | undefined { + const prefix = "codex_app_server."; + return stream?.startsWith(prefix) ? stream.slice(prefix.length) : stream; +} + +function specificToolName(value: string | undefined): string | undefined { + if (!value || value === "tool" || value === "item" || value === "tool_call" || value === "tool-call") return undefined; + return value; +} + +function toolCallIdFromPayload(data: Record): string | undefined { + const toolCall = toolCallRecordFromPayload(data); + return stringValue(data.toolCallId) + ?? stringValue(data.callId) + ?? stringValue(data.id) + ?? stringValue(toolCall?.toolCallId) + ?? stringValue(toolCall?.callId) + ?? stringValue(toolCall?.id); +} + +function toolNameFromPayload(data: Record): string | undefined { + const toolCall = toolCallRecordFromPayload(data); + const fn = recordValue(toolCall?.function) ?? recordValue(data.function); + return stringValue(data.toolName) + ?? stringValue(data.name) + ?? stringValue(data.command) + ?? stringValue(toolCall?.toolName) + ?? stringValue(toolCall?.name) + ?? stringValue(fn?.name); +} + +function toolTitleFromPayload(data: Record, fallback?: string): string | undefined { + const meta = recordValue(data.meta) ?? recordValue(data.metadata); + return stringValue(data.title) + ?? stringValue(data.label) + ?? commandFromPayload(data) + ?? stringValue(meta?.title) + ?? fallback; +} + +function toolDescriptionFromPayload(data: Record): string | undefined { + const meta = recordValue(data.meta) ?? recordValue(data.metadata); + return stringValue(data.description) + ?? stringValue(data.subtitle) + ?? stringValue(meta?.description) + ?? stringValue(meta?.subtitle); +} + +function toolMetadataFromPayload(data: Record, title?: string, description?: string): Record | undefined { + const base = recordValue(data.metadata) ?? recordValue(data.meta); + const providerDisplayName = stringValue(data.providerName) ?? stringValue(recordValue(data.provider)?.displayName); + const providerIconUrl = stringValue(data.providerIconUrl) ?? stringValue(recordValue(data.provider)?.iconUrl); + const provider = providerDisplayName || providerIconUrl + ? stripUndefined({ displayName: providerDisplayName, iconUrl: providerIconUrl }) + : undefined; + const display = stripUndefined({ + displayName: title, + description, + iconUrl: stringValue(data.iconUrl), + provider, + }); + const hasDisplay = Object.keys(display).length > 0; + if (!base && !hasDisplay) return undefined; + return stripUndefined({ + ...(base ?? {}), + ...display, + provider: provider ?? recordValue(base?.provider), + }); +} + +function toolInputFromPayload(data: Record): unknown { + const toolCall = toolCallRecordFromPayload(data); + const fn = recordValue(toolCall?.function) ?? recordValue(data.function); + const value = data.args + ?? data.input + ?? data.arguments + ?? data.parameters + ?? toolCall?.args + ?? toolCall?.input + ?? toolCall?.arguments + ?? fn?.arguments; + return typeof value === "string" ? parseMaybeJSONValue(value) : value; +} + +function toolOutputFromPayload(data: Record, fallback?: unknown, toolName?: string): unknown { + void toolName; + const toolResult = recordValue(data.toolResult) ?? recordValue(data.tool_result); + const value = data.output + ?? data.result + ?? data.response + ?? data.content + ?? data.text + ?? data.partialResult + ?? toolResult?.output + ?? toolResult?.result + ?? toolResult?.response + ?? toolResult?.content + ?? toolResult?.text + ?? fallback; + return isStatusOnlyToolOutput(value) ? undefined : value; +} + +function isCommandToolName(toolName: string | undefined): boolean { + const normalized = toolName?.toLowerCase(); + return normalized === "bash" || normalized === "exec" || normalized === "shell" || normalized === "command"; +} + +function commandPartFields(data: Record): Record { + const result = recordValue(data.result); + const output = recordValue(data.output); + const response = recordValue(data.response); + const input = recordValue(data.input) ?? recordValue(data.args) ?? recordValue(data.arguments); + const details = recordValue(data.details) ?? recordValue(result?.details) ?? recordValue(output?.details) ?? recordValue(response?.details); + return stripUndefined({ + aggregated: stringValue(data.aggregated) ?? stringValue(result?.aggregated) ?? stringValue(output?.aggregated), + command: commandFromPayload(data), + cwd: stringValue(data.cwd) ?? stringValue(input?.cwd) ?? stringValue(result?.cwd) ?? stringValue(output?.cwd), + details: data.details ?? result?.details ?? output?.details ?? response?.details, + exitCode: numberValue(data.exitCode) ?? numberValue(data.exit_code) ?? numberValue(details?.exitCode) ?? numberValue(details?.exit_code) ?? numberValue(result?.exitCode) ?? numberValue(result?.exit_code) ?? numberValue(output?.exitCode) ?? numberValue(output?.exit_code), + response: data.response, + result: data.result, + status: stringValue(data.status) ?? stringValue(details?.status), + stderr: stringValue(data.stderr) ?? stringValue(result?.stderr) ?? stringValue(output?.stderr), + stdout: stringValue(data.stdout) ?? stringValue(result?.stdout) ?? stringValue(output?.stdout), + }); +} + +function commandFromPayload(data: Record): string | undefined { + const input = recordValue(data.input) ?? recordValue(data.args) ?? recordValue(data.arguments); + const toolCall = toolCallRecordFromPayload(data); + const toolInput = recordValue(toolCall?.input) ?? recordValue(toolCall?.args) ?? recordValue(toolCall?.arguments); + const details = recordValue(data.details) ?? recordValue(recordValue(data.result)?.details) ?? recordValue(recordValue(data.output)?.details); + return stringValue(data.command) + ?? stringValue(data.cmd) + ?? stringValue(input?.command) + ?? stringValue(input?.cmd) + ?? stringValue(toolInput?.command) + ?? stringValue(toolInput?.cmd) + ?? stringValue(details?.command); +} + +function isStatusOnlyToolOutput(value: unknown): boolean { + const record = recordValue(value); + if (!record) return false; + const keys = Object.keys(record); + return keys.length > 0 && keys.every((key) => + key === "action" || + key === "finalUrl" || + key === "final_url" || + key === "phase" || + key === "queries" || + key === "query" || + key === "queryUnavailable" || + key === "state" || + key === "status" || + key === "url" + ); +} + +function toolProgressText(data: Record): string | undefined { + return stringValue(data.text) + ?? stringValue(data.progressText) + ?? stringValue(data.progressSummary) + ?? stringValue(data.message) + ?? stringValue(data.summary) + ?? stringValue(data.status) + ?? stringValue(data.phase) + ?? stringValue(data.name); +} + +function toolItemOutput(data: Record): unknown { + return data.progressText ?? data.summary ?? data.output ?? data.result ?? data.partialResult ?? data.error; +} + +function toolCallRecordFromPayload(data: Record): Record | undefined { + const direct = recordValue(data.toolCall) ?? recordValue(data.tool_call) ?? recordValue(data.call); + if (direct) return direct; + const content = arrayValue(data.content); + if (!content) return undefined; + for (const part of content) { + const record = recordValue(part); + const type = stringValue(record?.type); + if (record && (!type || type === "toolCall" || type === "tool_call" || type === "function_call")) return record; + } + return undefined; +} + +function parseMaybeJSONValue(value: string): unknown { + const trimmed = value.trim(); + if (!trimmed) return value; + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value; + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +function isToolInputDeltaPhase(value: string | undefined): boolean { + return value === "delta" || value === "input_delta" || value === "args_delta" || value === "arguments_delta" || value === "toolcall_delta"; +} + +function beeperCustomEvents(name: string, payload: unknown): AGUIEvent[] { + const normalized = name === "sources" ? "source" + : name === "documents" ? "document" + : name === "files" ? "file" + : name; + if (normalized === "source") { + const events = sourceEventsFromPayload(payload, "source"); + return events.length > 0 ? events : mapOpenClawCustom(name, payload); + } + if (normalized === "document") { + const events = documentEventsFromPayload(payload); + return events.length > 0 ? events : mapOpenClawCustom(name, payload); + } + if (normalized === "file") { + const events = fileEventsFromPayload(payload); + return events.length > 0 ? events : mapOpenClawCustom(name, payload); + } + if (normalized === "data") { + const record = recordValue(payload); + return mapOpenClawCustom("com.beeper.data", record && stringValue(record.name) ? payload : { name: "openclaw.data", value: payload }); + } + return mapOpenClawCustom(name, payload); +} + +function toolArtifactEvents(toolName: string | undefined, output: unknown): AGUIEvent[] { + const name = toolName?.toLowerCase(); + if (!name) return []; + const value = typeof output === "string" ? parseMaybeJSONValue(output) : output; + if (name === "web_search" || name === "search" || name.includes("web_search")) { + return sourceEventsFromPayload(value, "web_search"); + } + if (name === "fetch" || name === "web_fetch" || name.includes("fetch")) { + return [ + ...sourceEventsFromPayload(value, "fetch"), + ...documentEventsFromPayload(value), + ]; + } + return []; +} + +function sourceEventsFromPayload(payload: unknown, appearanceKind: string): AGUIEvent[] { + const records = artifactRecords(payload, ["items", "results", "sources", "urls"]); + return records.flatMap((record) => { + const source = sourcePayload(record, appearanceKind); + return source ? mapOpenClawCustom("com.beeper.source", source) : []; + }); +} + +function documentEventsFromPayload(payload: unknown): AGUIEvent[] { + const records = artifactRecords(payload, ["documents", "docs", "items", "results"]); + return records.flatMap((record) => { + const document = documentPayload(record); + return document ? mapOpenClawCustom("com.beeper.document", document) : []; + }); +} + +function answerURLSourceEvents(text: string, emitted: Set): AGUIEvent[] { + const matches = text.matchAll(/\bhttps?:\/\/[^\s<>)\]}"]+/giu); + const events: AGUIEvent[] = []; + for (const match of matches) { + const url = trimURLPunctuation(match[0]); + if (!url || emitted.has(url)) continue; + emitted.add(url); + events.push(...mapOpenClawCustom("com.beeper.source", stripUndefined({ + appearances: [{ kind: "answer" }], + sourceId: url, + title: hostnameTitle(url), + url, + }))); + } + return events; +} + +function trimURLPunctuation(url: string): string { + return url.replace(/[.,;:!?]+$/u, ""); +} + +function hostnameTitle(url: string): string | undefined { + try { + return new URL(url).hostname || undefined; + } catch { + return undefined; + } +} + +function fileEventsFromPayload(payload: unknown): AGUIEvent[] { + const records = artifactRecords(payload, ["files", "items", "results"]); + return records.flatMap((record) => { + const url = stringValue(record.url) ?? stringValue(record.mxc) ?? stringValue(record.uri); + const title = stringValue(record.title) ?? stringValue(record.name) ?? stringValue(record.filename); + if (!url && !title) return []; + return mapOpenClawCustom("com.beeper.file", stripUndefined({ + id: stringValue(record.id), + mediaType: stringValue(record.mediaType) ?? stringValue(record.mimeType), + title, + url, + })); + }); +} + +function artifactRecords(payload: unknown, arrayKeys: string[]): Record[] { + const directArray = arrayValue(payload); + if (directArray) return directArray.flatMap((item) => recordValue(item) ? [recordValue(item)!] : []); + const record = recordValue(payload); + if (!record) return []; + for (const key of arrayKeys) { + const values = arrayValue(record[key]); + if (values) return values.flatMap((item) => recordValue(item) ? [recordValue(item)!] : []); + } + return [record]; +} + +function sourcePayload(record: Record, appearanceKind: string): Record | undefined { + const url = stringValue(record.url) ?? stringValue(record.finalUrl) ?? stringValue(record.final_url); + const title = stringValue(record.title) ?? stringValue(record.name); + const sourceId = stringValue(record.sourceId) ?? stringValue(record.id) ?? url ?? title; + if (!sourceId && !url && !title) return undefined; + return stripUndefined({ + appearances: arrayValue(record.appearances) ?? [{ kind: appearanceKind }], + author: stringValue(record.author), + description: stringValue(record.description) ?? stringValue(record.summary) ?? stringValue(record.text), + faviconUrl: stringValue(record.faviconUrl) ?? stringValue(record.favicon_url), + finalUrl: stringValue(record.finalUrl) ?? stringValue(record.final_url), + highlights: arrayValue(record.highlights), + imageUrl: stringValue(record.imageUrl) ?? stringValue(record.image_url), + metadata: recordValue(record.metadata), + publishedAt: stringValue(record.publishedAt) ?? stringValue(record.published_at), + siteName: stringValue(record.siteName) ?? stringValue(record.site_name), + sourceId, + title, + url, + }); +} + +function documentPayload(record: Record): Record | undefined { + const text = stringValue(record.markdown) ?? stringValue(record.text) ?? stringValue(record.content); + const url = stringValue(record.url) ?? stringValue(record.finalUrl) ?? stringValue(record.final_url); + const title = stringValue(record.title) ?? stringValue(record.name); + const id = stringValue(record.id) ?? stringValue(record.requestId) ?? stringValue(record.request_id) ?? url ?? title; + if (!id || !text) return undefined; + return stripUndefined({ + id, + markdown: stringValue(record.markdown), + mediaType: stringValue(record.mediaType) ?? stringValue(record.mimeType) ?? "text/markdown", + metadata: recordValue(record.metadata), + sourceId: stringValue(record.sourceId) ?? id, + text: stringValue(record.text) ?? text, + title, + url, + }); +} + +function usageFromPayload(data: Record): Record | undefined { + const usage = recordValue(data.usage) ?? data; + const promptTokens = intValue(usage.promptTokens) ?? intValue(usage.prompt_tokens) ?? intValue(usage.inputTokens) ?? intValue(usage.input); + const completionTokens = intValue(usage.completionTokens) ?? intValue(usage.completion_tokens) ?? intValue(usage.outputTokens) ?? intValue(usage.output); + const reasoningTokens = intValue(usage.reasoningTokens) ?? intValue(usage.reasoning_tokens); + const totalTokens = intValue(usage.totalTokens) ?? intValue(usage.total_tokens) ?? intValue(usage.total); + const contextLimit = intValue(usage.contextLimit) ?? intValue(usage.context_limit) ?? intValue(data.contextTokenBudget); + const out = stripUndefined({ promptTokens, completionTokens, reasoningTokens, totalTokens, contextLimit }); + return Object.keys(out).length > 0 ? out : undefined; +} + +function lifecycleModelMetadata(data: Record): Record | undefined { + const model = stripUndefined({ + model: stringValue(data.model) ?? stringValue(data.modelId) ?? stringValue(data.selectedModel), + provider: stringValue(data.provider), + reasoning: data.reasoning ?? data.reasoningLevel, + requestId: stringValue(data.requestId) ?? stringValue(data.request_id), + serviceTier: stringValue(data.serviceTier) ?? stringValue(data.service_tier), + systemFingerprint: stringValue(data.systemFingerprint) ?? stringValue(data.system_fingerprint), + }); + return Object.keys(model).length > 0 ? model : undefined; +} + +function lifecycleContextMetadata(data: Record): Record | undefined { + const context = stripUndefined({ + contextTokenBudget: intValue(data.contextTokenBudget), + contextWindowReferenceTokens: intValue(data.contextWindowReferenceTokens), + contextWindowSource: stringValue(data.contextWindowSource), + promptTokens: intValue(data.promptTokens) ?? intValue(data.prompt_tokens), + sessionId: stringValue(data.sessionId), + sessionKey: stringValue(data.sessionKey), + }); + return Object.keys(context).length > 0 ? context : undefined; +} + +function isToolItemType(value: string | undefined): boolean { + return value === "toolCall" + || value === "tool_call" + || value === "tool-call" + || value === "toolUse" + || value === "tool_use" + || value === "tool-use" + || value === "toolResult" + || value === "tool_result" + || value === "tool-result" + || value === "command" + || value === "patch"; +} + +function isCompletePhase(value: string | undefined): boolean { + return value === "complete" || value === "completed" || value === "end" || value === "ended" || value === "finish" || value === "finished" || value === "done"; +} + +function emptyToolResultContent(output: unknown, error: unknown): string | undefined { + return output === undefined && error === undefined ? "{}" : undefined; +} + +function createBeeperReplyStreamEmitter(base: { + agentId: string; + hostRuntime?: OpenClawHostRuntime; + localEvents: LocalEventBus; + roomId: string; + runId: string; + sessionId: string; + sessionKey: string; + threadRoot?: string; +}) { + const channelRuntime = getBeeperChannelRuntimeForHost(base.hostRuntime); + if (!channelRuntime) { + throw new Error("OpenClaw Beeper requires the Beeper channel runtime for native rich streaming"); + } + const publisher = channelRuntime.createStreamPublisher({ + agentId: base.agentId, + roomId: base.roomId, + runId: base.runId, + sessionKey: base.sessionKey, + ...(base.threadRoot ? { threadRoot: base.threadRoot } : {}), + }); + const approvalState = createApprovalRunState(); + let hasPublished = false; + let finalized = false; + let lastVisibleText = ""; + let lastReasoningText = ""; + let startPromise: Promise | undefined; + const externalTasks = new Set>(); + const toolInputs = new Map(); + const toolNames = new Map(); + const pendingToolCalls = new Set(); + const pendingToolWaiters = new Set<() => void>(); + const startedToolCalls = new Set(); + const emittedSourceUrls = new Set(); + let latestUsage: unknown; + const emit = (event: string, payload: Record) => { + base.localEvents.emit({ + event, + payload: stripUndefined({ + agentId: base.agentId, + runId: base.runId, + sessionId: base.sessionId, + sessionKey: base.sessionKey, + ...payload, + }), + }); + }; + const ensureStarted = async () => { + if (hasPublished || finalized) return; + if (!startPromise) { + startPromise = (async () => { + channelRuntime.debug("openclaw_beeper_stream_starting", { + agentId: base.agentId, + roomId: base.roomId, + runId: base.runId, + sessionId: base.sessionId, + sessionKey: base.sessionKey, + }); + await publisher.start(); + hasPublished = true; + channelRuntime.debug("openclaw_beeper_stream_started", { + agentId: base.agentId, + eventId: publisher.targetEventId, + roomId: base.roomId, + runId: base.runId, + sessionId: base.sessionId, + sessionKey: base.sessionKey, + }); + })().catch((error) => { + startPromise = undefined; + throw error; + }); + } + await startPromise; + }; + const markPublished = () => { + if (hasPublished) return; + hasPublished = true; + channelRuntime.debug("openclaw_beeper_stream_started", { + agentId: base.agentId, + eventId: publisher.targetEventId, + roomId: base.roomId, + runId: base.runId, + sessionId: base.sessionId, + sessionKey: base.sessionKey, + }); + }; + const publish = async (parts: Iterable) => { + if (finalized) return; + const list = [...parts]; + if (list.length === 0) return; + channelRuntime.debug("openclaw_beeper_stream_publish", { + count: list.length, + firstType: stringValue(list[0]?.type), + roomId: base.roomId, + runId: base.runId, + }); + await publisher.publishMany(list); + markPublished(); + channelRuntime.recordOutboundActivity(); + }; + const publishPart = async (part: Parameters[0]) => { + if (finalized) return; + channelRuntime.debug("openclaw_beeper_stream_publish_part", { + kind: part.kind, + roomId: base.roomId, + runId: base.runId, + }); + await publisher.publishPart(part); + markPublished(); + channelRuntime.recordOutboundActivity(); + }; + const publishParts = async (parts: Array[0]>) => { + if (parts.length === 0 || finalized) return; + await publisher.publishParts(parts); + markPublished(); + channelRuntime.recordOutboundActivity(); + }; + const publishCustomEvents = async (events: AGUIEvent[]) => { + const customParts: Array[0]> = []; + const rawEvents: AGUIEvent[] = []; + for (const event of events) { + if (event.type === "CUSTOM") { + customParts.push(stripUndefined({ + kind: "custom", + name: stringValue(event.name) ?? "openclaw.data", + value: event.value, + })); + } else { + rawEvents.push(event); + } + } + await publishParts(customParts); + if (rawEvents.length > 0) await publish(rawEvents); + }; + const trackExternal = (promise: Promise) => { + let tracked: Promise; + tracked = promise.catch((error) => { + channelRuntime.debug("openclaw_beeper_external_stream_event_failed", { + error: errorText(error), + roomId: base.roomId, + runId: base.runId, + }); + }).finally(() => { + externalTasks.delete(tracked); + }); + externalTasks.add(tracked); + }; + const drainExternal = async () => { + while (externalTasks.size > 0) { + await Promise.all([...externalTasks]); + } + }; + const textPayload = async (payload: unknown, source: "partial" | "block" | "final" = "partial") => { + const text = replyPayloadText(payload); + channelRuntime.debug("openclaw_beeper_text_payload_received", { + hasDelta: stringValue(recordValue(payload)?.delta) !== undefined, + source, + textLength: text?.length ?? 0, + }); + if (!text) return; + const sourceEvents = source === "final" ? answerURLSourceEvents(text, emittedSourceUrls) : []; + if (isWorkingPlaceholder(text)) { + channelRuntime.debug("openclaw_beeper_text_payload_suppressed", { + reason: "working_placeholder", + source, + textLength: text.length, + }); + if (source !== "final") { + await ensureStarted(); + } + return; + } + const explicitDelta = stringValue(recordValue(payload)?.delta); + const delta = explicitDelta ?? visibleTextDelta(lastVisibleText, text); + lastVisibleText = nextVisibleText(lastVisibleText, text, delta); + if (!delta && sourceEvents.length === 0) { + channelRuntime.debug("openclaw_beeper_text_payload_suppressed", { + reason: "empty_delta", + source, + textLength: text.length, + }); + return; + } + channelRuntime.debug("openclaw_beeper_text_payload_delta", { + deltaLength: delta.length, + source, + textLength: text.length, + }); + emit("assistant.delta", { delta, source, text }); + if (delta) await publishPart({ kind: "text", text: delta }); + if (sourceEvents.length > 0) await publishCustomEvents(sourceEvents); + }; + const reasoningPayload = async (payload: unknown) => { + const text = reasoningPayloadText(payload); + if (!text) return; + const payloadRecord = recordValue(payload); + const explicitDelta = reasoningDeltaText(payloadRecord); + const isSnapshot = booleanValue(payloadRecord?.isReasoningSnapshot) === true; + const delta = explicitDelta && !isSnapshot + ? explicitDelta + : (text.startsWith(lastReasoningText) ? text.slice(lastReasoningText.length) : text); + lastReasoningText = explicitDelta && !isSnapshot + ? `${lastReasoningText}${explicitDelta}` + : text; + if (!delta) return; + emit("thinking.delta", { delta, text }); + await publishPart({ kind: "reasoning", text: delta }); + }; + const toolIdFor = (payload: Record, fallback: string) => + toolCallIdFromPayload(payload) ?? stringValue(payload.itemId) ?? stringValue(payload.approvalId) ?? fallback; + const fallbackToolIdForName = (name: string | undefined, fallback: string) => `tool:${name || fallback}`; + const rememberTool = (toolCallId: string, toolName: string | undefined, input?: unknown) => { + if (toolName) toolNames.set(toolCallId, toolName); + if (input !== undefined) toolInputs.set(toolCallId, input); + }; + const rememberedToolName = (toolCallId: string, fallback?: string) => toolNames.get(toolCallId) ?? fallback; + const markToolPending = (toolCallId: string | undefined) => { + if (toolCallId) pendingToolCalls.add(toolCallId); + }; + const markToolComplete = (toolCallId: string | undefined) => { + if (!toolCallId) return; + pendingToolCalls.delete(toolCallId); + if (pendingToolCalls.size === 0) { + for (const resolve of pendingToolWaiters) resolve(); + pendingToolWaiters.clear(); + } + }; + const waitForPendingTools = async (timeoutMs = 1200) => { + if (pendingToolCalls.size === 0) return; + channelRuntime.debug("openclaw_beeper_stream_waiting_for_tools", { + pendingToolCalls: [...pendingToolCalls], + roomId: base.roomId, + runId: base.runId, + timeoutMs, + }); + await Promise.race([ + new Promise((resolve) => { + pendingToolWaiters.add(resolve); + }), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); + if (pendingToolCalls.size > 0) { + channelRuntime.debug("openclaw_beeper_stream_tools_still_pending", { + pendingToolCalls: [...pendingToolCalls], + roomId: base.roomId, + runId: base.runId, + }); + } + }; + const completePendingToolsForFinal = async () => { + if (pendingToolCalls.size === 0) return; + const toolCallIds = [...pendingToolCalls]; + channelRuntime.debug("openclaw_beeper_stream_completing_pending_tools", { + pendingToolCalls: toolCallIds, + roomId: base.roomId, + runId: base.runId, + }); + for (const toolCallId of toolCallIds) { + const toolName = rememberedToolName(toolCallId, "tool") ?? "tool"; + await publishPart({ + kind: "tool_result", + state: "complete", + text: "{}", + toolCallId, + toolName, + }); + markToolComplete(toolCallId); + } + }; + return { + start: ensureStarted, + trackExternal, + assistantMessageStart: () => { + emit("assistant.message.start", {}); + }, + reasoningEnd: async () => { + emit("thinking.end", {}); + await publishPart({ kind: "reasoning_end" }); + }, + reasoningPayload, + textPayload, + activity: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const text = stringValue(data.text) + ?? stringValue(data.progressText) + ?? stringValue(data.progressSummary) + ?? stringValue(data.message) + ?? stringValue(data.status) + ?? stringValue(data.phase) + ?? stringValue(data.title); + if (!text) return; + const activityType = stringValue(data.activityType) ?? stringValue(data.type) ?? "activity"; + if (isWorkingPlaceholder(text)) { + channelRuntime.debug("openclaw_beeper_activity_suppressed", { + activityType, + reason: "working_placeholder", + roomId: base.roomId, + runId: base.runId, + }); + await ensureStarted(); + return; + } + emit("activity.updated", { activityType, text }); + await publishPart({ + kind: "activity", + activityType, + content: stripUndefined({ + label: stringValue(data.label) ?? stringValue(data.title) ?? stringValue(data.name), + phase: stringValue(data.phase), + state: stringValue(data.state) ?? stringValue(data.status) ?? "running", + text, + }), + replace: true, + }); + }, + toolStart: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const toolName = toolNameFromPayload(data); + const toolCallId = toolIdFor(data, fallbackToolIdForName(toolName, "tool")); + const input = toolInputFromPayload(data); + const title = toolTitleFromPayload(data); + const description = toolDescriptionFromPayload(data); + const metadata = toolMetadataFromPayload(data, title, description); + rememberTool(toolCallId, toolName, input); + emit("tool.call.started", { + input, + phase: stringValue(data.phase), + toolCallId, + toolName, + }); + if (recordValue(data.approval)) { + if (startedToolCalls.has(toolCallId)) return; + startedToolCalls.add(toolCallId); + markToolPending(toolCallId); + await publishPart(stripUndefined({ + approval: recordValue(data.approval), + description, + dynamic: booleanValue(data.dynamic), + index: numberValue(data.index), + input, + kind: "tool_start", + metadata, + providerExecuted: booleanValue(data.providerExecuted), + startedAtMs: numberValue(data.startedAt) ?? numberValue(data.startedAtMs), + title, + toolCallId, + toolName, + })); + return; + } + markToolPending(toolCallId); + await publishPart(stripUndefined({ + kind: "tool_start", + description, + dynamic: booleanValue(data.dynamic), + index: numberValue(data.index), + input, + metadata, + providerExecuted: booleanValue(data.providerExecuted), + startedAtMs: numberValue(data.startedAt) ?? numberValue(data.startedAtMs), + title, + toolCallId, + toolName, + })); + }, + toolInputDelta: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const rawToolName = toolNameFromPayload(data); + const toolCallId = toolIdFor(data, fallbackToolIdForName(rawToolName, "tool_delta")); + const toolName = rememberedToolName(toolCallId, rawToolName); + const input = toolInputFromPayload(data); + const inputTextDelta = stringValue(data.inputTextDelta) ?? stringValue(data.argsDelta) ?? stringValue(data.argumentsDelta) ?? stringValue(data.delta); + const title = toolTitleFromPayload(data); + const description = toolDescriptionFromPayload(data); + const metadata = toolMetadataFromPayload(data, title, description); + rememberTool(toolCallId, toolName, input); + markToolPending(toolCallId); + emit("tool.call.input.delta", { + inputTextDelta, + toolCallId, + toolName, + }); + await publishPart(stripUndefined({ + description, + delta: inputTextDelta, + input, + kind: "tool_input", + metadata, + providerExecuted: booleanValue(data.providerExecuted), + startedAtMs: numberValue(data.startedAt) ?? numberValue(data.startedAtMs), + title, + toolCallId, + toolName, + })); + }, + toolResult: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const toolCallId = toolIdFor(data, "tool_result"); + const toolName = rememberedToolName(toolCallId, toolNameFromPayload(data)); + const input = data.input ?? toolInputs.get(toolCallId); + const title = toolTitleFromPayload(data); + const description = toolDescriptionFromPayload(data); + const metadata = toolMetadataFromPayload(data, title, description); + const commandTool = isCommandToolName(toolName); + const error = data.error ?? (booleanValue(data.isError) ? toolOutputFromPayload(data, payload, toolName) : undefined); + const output = commandTool ? firstNonUndefined(data.output, data.result, data.response, data.value) : toolOutputFromPayload(data, payload, toolName); + markToolComplete(toolCallId); + emit("tool.call.completed", { + output, + toolCallId, + toolName, + }); + if (output !== undefined || error !== undefined) { + await publishPart(stripUndefined({ + completedAtMs: numberValue(data.completedAt) ?? numberValue(data.completedAtMs), + description, + error, + input, + kind: "tool_result", + metadata, + output: error === undefined ? output : undefined, + providerExecuted: booleanValue(data.providerExecuted), + ...(commandTool ? commandPartFields(data) : {}), + title, + toolCallId, + toolName, + })); + } + await publishCustomEvents(toolArtifactEvents(toolName, output)); + }, + itemEvent: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const rawToolName = toolNameFromPayload(data); + const itemType = stringValue(data.type); + const kind = stringValue(data.kind); + const hasToolIdentity = Boolean(rawToolName || toolCallIdFromPayload(data) || kind === "tool" || kind === "command" || kind === "patch"); + if (!hasToolIdentity && !isToolItemType(itemType)) return; + const toolCallId = toolIdFor(data, stringValue(data.kind) ?? "item"); + const toolName = rememberedToolName(toolCallId, rawToolName ?? specificToolName(kind) ?? specificToolName(itemType) ?? "tool"); + const input = toolInputFromPayload(data); + const inputTextDelta = stringValue(data.inputTextDelta) ?? stringValue(data.argsDelta) ?? stringValue(data.argumentsDelta) ?? (isToolInputDeltaPhase(stringValue(data.phase)) ? stringValue(data.delta) : undefined); + const title = toolTitleFromPayload(data, stringValue(data.progressText) ?? stringValue(data.summary) ?? rawToolName ?? itemType ?? kind); + const description = toolDescriptionFromPayload(data); + const metadata = toolMetadataFromPayload(data, title, description); + const commandTool = isCommandToolName(toolName); + const output = commandTool ? firstNonUndefined(data.output, data.result, data.response, data.value, toolItemOutput(data)) : toolItemOutput(data); + const phase = stringValue(data.phase); + const status = stringValue(data.status); + const preliminary = !isCompletePhase(phase) && !isCompletePhase(status); + const error = data.error; + rememberTool(toolCallId, toolName, input); + if (preliminary) markToolPending(toolCallId); + else markToolComplete(toolCallId); + emit("tool.call.updated", { + output, + phase, + preliminary, + toolCallId, + toolName, + }); + const parts: Array[0]> = []; + if (inputTextDelta) { + parts.push(stripUndefined({ + description, + delta: inputTextDelta, + input, + kind: "tool_input", + metadata, + providerExecuted: booleanValue(data.providerExecuted), + startedAtMs: numberValue(data.startedAt) ?? numberValue(data.startedAtMs), + title, + toolCallId, + toolName, + })); + } + if (output !== undefined || error !== undefined || !inputTextDelta) { + parts.push(stripUndefined({ + description, + error, + input: input ?? toolInputs.get(toolCallId), + kind: "tool_result", + metadata, + output: error === undefined ? output : undefined, + preliminary, + text: emptyToolResultContent(output, error), + completedAtMs: numberValue(data.completedAt) ?? numberValue(data.completedAtMs), + providerExecuted: booleanValue(data.providerExecuted), + ...(commandTool ? commandPartFields(data) : {}), + title, + toolCallId, + toolName, + })); + } + await publishParts(parts); + if (!preliminary) await publishCustomEvents(toolArtifactEvents(toolName, output)); + }, + planUpdate: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const output = stringValue(data.explanation) ?? stringValue(data.title); + if (!output) return; + const phase = stringValue(data.phase); + const preliminary = phase !== "complete" && phase !== "end"; + rememberTool("plan", "plan"); + if (preliminary) markToolPending("plan"); + else markToolComplete("plan"); + emit("tool.call.completed", { + output, + preliminary, + toolCallId: "plan", + toolName: "plan", + }); + await publishPart({ + kind: "tool_result", + output, + preliminary, + toolCallId: "plan", + toolName: "plan", + }); + const steps = arrayValue(data.steps)?.filter((step): step is string => typeof step === "string"); + if (steps?.length) { + await publishPart({ delta: [{ op: "add", path: "/plan", value: steps }], kind: "state_delta" }); + } + }, + stateSnapshot: async (payload: unknown) => { + emit("state.snapshot", { snapshot: payload }); + await publishPart({ kind: "state_snapshot", value: payload }); + }, + customData: async (name: string, payload: unknown) => { + emit(`${name}.event`, { value: payload }); + await publishCustomEvents(beeperCustomEvents(name, payload)); + }, + lifecycleEvent: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const phase = stringValue(data.phase); + const usage = usageFromPayload(data); + if (usage !== undefined) latestUsage = usage; + const model = lifecycleModelMetadata(data); + const context = lifecycleContextMetadata(data); + if (phase) { + emit("lifecycle.phase", { phase }); + if (!isCompletePhase(phase) && phase !== "failed" && phase !== "error") await ensureStarted(); + } + const events = [ + ...(model ? mapOpenClawCustom("com.beeper.data", { name: "openclaw.model", value: model }) : []), + ...(context ? mapOpenClawCustom("com.beeper.data", { name: "openclaw.context", value: context }) : []), + ...(usage !== undefined ? mapOpenClawCustom("com.beeper.data", { name: "openclaw.usage", value: usage }) : []), + ]; + if (events.length > 0) { + emit("lifecycle.metadata", { + context, + model, + phase, + usage, + }); + await publishCustomEvents(events); + } + }, + raw: async (source: string, payload: unknown) => { + emit("raw.event", { source, value: payload }); + await publishPart({ kind: "raw", source, value: payload }); + }, + approvalEvent: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const phase = stringValue(data.phase); + if (phase === "requested") { + const approvalId = stringValue(data.approvalId) ?? stringValue(data.approvalSlug); + const toolCallId = stringValue(data.toolCallId) ?? stringValue(data.itemId); + const toolName = rememberedToolName(toolCallId ?? "", stringValue(data.kind) ?? stringValue(data.command)); + const message = stringValue(data.message) ?? stringValue(data.reason) ?? stringValue(data.title); + if (toolCallId) rememberTool(toolCallId, toolName); + emit("approval.requested", { + approvalId, + message, + toolCallId, + toolName, + }); + await publish([mapOpenClawApprovalRequest(approvalState, stripUndefined({ approvalId, message, toolCallId, toolName }))]); + return; + } + if (phase === "resolved" || phase === "complete" || stringValue(data.status)) { + const approvalId = stringValue(data.approvalId) ?? stringValue(data.approvalSlug); + const status = stringValue(data.status); + const approved = status === "approved" || status === "allow" || status === "approve"; + if (!approvalId) return; + const toolCallId = stringValue(data.toolCallId) ?? stringValue(data.itemId); + emit("approval.resolved", { + approvalId, + approved, + decision: status, + toolCallId, + }); + await publish([mapOpenClawApprovalResponse(stripUndefined({ + approvalId, + approved, + approvedAlways: booleanValue(data.always) ?? booleanValue(data.approvedAlways), + toolCallId, + }))]); + } + }, + debug: (event: string, payload: Record) => { + channelRuntime.debug(event, { + roomId: base.roomId, + runId: base.runId, + sessionId: base.sessionId, + sessionKey: base.sessionKey, + ...payload, + }); + }, + commandOutput: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const toolName = stringValue(data.name) ?? stringValue(data.title) ?? "command"; + const phase = stringValue(data.phase); + const status = stringValue(data.status); + const complete = isCompletePhase(phase) || isCompletePhase(status); + const toolCallId = toolIdFor(data, fallbackToolIdForName(toolName, "command")); + const input = toolInputFromPayload(data); + const title = isCommandToolName(toolName) ? undefined : toolTitleFromPayload(data, toolName); + const description = toolDescriptionFromPayload(data); + const metadata = toolMetadataFromPayload(data, title, description); + const output = isCommandToolName(toolName) + ? firstNonUndefined(data.output, data.result, data.response, data.value, complete ? undefined : toolProgressText(data)) + : toolOutputFromPayload(data, complete ? undefined : toolProgressText(data), toolName); + rememberTool(toolCallId, toolName, input); + if (complete) markToolComplete(toolCallId); + else markToolPending(toolCallId); + emit("tool.call.completed", { + output, + preliminary: !complete, + toolCallId, + toolName, + }); + if (output !== undefined || !complete) { + await publishPart(stripUndefined({ + description, + input: input ?? toolInputs.get(toolCallId), + kind: "tool_result", + metadata, + output, + preliminary: !complete, + text: emptyToolResultContent(output, undefined), + ...(isCommandToolName(toolName) ? commandPartFields(data) : {}), + title, + toolCallId, + toolName, + })); + } + if (complete) { + await publishCustomEvents(toolArtifactEvents(toolName, output)); + } + }, + patchSummary: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const toolCallId = toolIdFor(data, "patch"); + const toolName = rememberedToolName(toolCallId, stringValue(data.name) ?? "patch"); + const output = data.summary ?? data; + const title = toolTitleFromPayload(data, "Patch"); + const description = toolDescriptionFromPayload(data); + const metadata = toolMetadataFromPayload(data, title, description); + rememberTool(toolCallId, toolName); + emit("tool.call.completed", { + output, + toolCallId, + toolName, + }); + await publishPart(stripUndefined({ + description, + input: toolInputs.get(toolCallId), + kind: "tool_result", + metadata, + output, + title, + toolCallId, + toolName, + })); + await publishCustomEvents(toolArtifactEvents(toolName, output)); + }, + finish: async (payload?: unknown) => { + if (payload !== undefined) await textPayload(payload, "final"); + await drainExternal(); + await waitForPendingTools(); + await drainExternal(); + await completePendingToolsForFinal(); + if (!hasPublished || finalized) return; + finalized = true; + channelRuntime.debug("openclaw_beeper_stream_finalizing", { + roomId: base.roomId, + runId: base.runId, + }); + await publisher.finalize({ + finishReason: "stop", + ...(latestUsage !== undefined ? { usage: latestUsage } : {}), + }); + channelRuntime.recordOutboundActivity(); + channelRuntime.clearActiveStream(base.sessionKey, publisher); + channelRuntime.debug("openclaw_beeper_stream_finalized", { + eventId: publisher.targetEventId, + roomId: base.roomId, + runId: base.runId, + }); + }, + fail: async (error: unknown) => { + if (finalized) return; + await drainExternal(); + finalized = true; + channelRuntime.debug("openclaw_beeper_stream_failing", { + error: errorText(error), + roomId: base.roomId, + runId: base.runId, + }); + await publisher.finalize({ + terminalPart: { + error: { message: errorText(error) }, + message: errorText(error), + runId: base.runId, + threadId: base.runId, + type: AGUIEventType.RUN_ERROR, + }, + }); + channelRuntime.recordOutboundActivity(); + channelRuntime.clearActiveStream(base.sessionKey, publisher); + }, + }; +} + +function replyPayloadText(payload: unknown): string | undefined { + if (typeof payload === "string") return payload; + const record = recordValue(payload); + if (!record) return undefined; + const direct = + stringValue(record.text) ?? + stringValue(record.body) ?? + stringValue(record.content) ?? + stringValue(record.textDelta) ?? + stringValue(record.text_delta) ?? + stringValue(record.delta); + if (direct) return direct; + const parts = arrayValue(record.parts) ?? arrayValue(record.content); + if (!parts) return undefined; + const chunks: string[] = []; + for (const part of parts) { + const partRecord = recordValue(part); + if (partRecord && !isVisibleTextPart(stringValue(partRecord.type))) continue; + const text = stringValue(partRecord?.text) ?? stringValue(partRecord?.content); + if (text) chunks.push(text); + } + return chunks.length > 0 ? chunks.join("") : undefined; +} + +function reasoningPayloadText(payload: unknown): string | undefined { + if (typeof payload === "string") return payload; + const record = recordValue(payload); + if (!record) return undefined; + return stringValue(record.text) + ?? stringValue(record.body) + ?? stringValue(record.reasoningText) + ?? stringValue(record.reasoning_text) + ?? stringValue(record.thinking) + ?? stringValue(record.reasoning) + ?? stringValue(record.summaryText) + ?? stringValue(record.summary_text) + ?? reasoningDeltaText(record) + ?? reasoningTextFromRecord(recordValue(record.item) ?? record, true); +} + +function reasoningDeltaText(record: Record | undefined): string | undefined { + if (!record) return undefined; + return stringValue(record.delta) + ?? stringValue(record.reasoningDelta) + ?? stringValue(record.reasoning_delta) + ?? stringValue(record.thinkingDelta) + ?? stringValue(record.thinking_delta) + ?? stringValue(record.summaryTextDelta) + ?? stringValue(record.summary_text_delta); +} + +function exposedCodexReasoningText(stream: string | undefined, data: Record): string | undefined { + const method = stringValue(data.method); + const item = recordValue(data.item) ?? recordValue(recordValue(data.params)?.item); + if ( + stream === "raw" || + stream === "item" || + method === "rawResponseItem/completed" || + method === "item/completed" + ) { + return reasoningTextFromRecord(item ?? data, false); + } + if (stream === "reasoning") { + return reasoningTextFromRecord(item ?? data, true); + } + return undefined; +} + +function reasoningTextFromRecord(record: Record | undefined, allowUntyped: boolean): string | undefined { + if (!record) return undefined; + if (stringValue(record.type) !== "reasoning" && !allowUntyped) { + return undefined; + } + if (!record.summary && !record.content) { + return undefined; + } + const chunks = [ + ...reasoningTextEntries(record.summary), + ...reasoningTextEntries(record.content), + ].filter((text) => text.trim().length > 0); + return chunks.length > 0 ? chunks.join("\n\n") : undefined; +} + +function reasoningTextEntries(value: unknown): string[] { + if (typeof value === "string") return [value]; + const entries = arrayValue(value); + if (!entries) return []; + return entries.flatMap((entry) => { + if (typeof entry === "string") return [entry]; + const record = recordValue(entry); + if (!record) return []; + const type = stringValue(record.type); + if (type && type !== "summary_text" && type !== "reasoning_text" && type !== "text") { + return []; + } + const text = stringValue(record.text) ?? stringValue(record.content); + return text ? [text] : []; + }); +} + +function isVisibleTextPart(type: string | undefined): boolean { + if (!type) return true; + return type === "text" || type === "output_text" || type === "assistant_text" || type === "markdown"; +} + +function isWorkingPlaceholder(text: string): boolean { + return /^working(?:\.{3}|\u2026)?$/iu.test(text.trim()); +} + +function visibleTextDelta(previous: string, next: string): string { + if (!next || next === previous) return ""; + if (!previous) return next; + if (next.startsWith(previous)) return next.slice(previous.length); + return next; +} + +function nextVisibleText(previous: string, next: string, delta: string): string { + if (!delta) return previous; + if (!previous || next.startsWith(previous)) return next; + return previous + delta; +} + +function relationSupplementalContext(matrix: Record): Record | undefined { + const relation = recordValue(matrix.relation); + const quote = recordValue(relation?.quote); + if (!quote) return undefined; + return { + quote: stripUndefined({ + id: stringValue(relation?.replyToEventId) ?? stringValue(relation?.targetEventId), + body: stringValue(quote.body), + sender: stringValue(quote.sender), + senderAllowed: true, + isQuote: true, + }), + }; +} + +function resolvePluginSession(runtime: OpenClawHostRuntime, sessionKey: string, agentId?: string): { entry?: Record; sessionKey: string } { + const getSessionEntry = runtime.agent?.session?.getSessionEntry; + const direct = recordValue(getSessionEntry?.({ agentId, sessionKey })); + if (direct) return { entry: direct, sessionKey }; + for (const item of sessionsFromPluginRuntime(runtime, { includeArchived: true })) { + if (stringValue(item.key) === sessionKey) return { entry: item, sessionKey }; + } + return { sessionKey }; +} + +function buildPluginSessionKey(agentId: string, label?: string): string { + const suffix = (label ?? randomUUID()).toLowerCase().replace(/[^a-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 48) || randomUUID(); + return `agent:${agentId}:beeper:${suffix}`; +} + +function sessionIdFromSessionKey(sessionKey: string): string { + return sessionKey.toLowerCase().replace(/[^a-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 96) || randomUUID(); +} + +function resolvePluginSessionFile( + runtime: OpenClawHostRuntime, + agentId: string, + sessionId: string, + entry?: Record, +): string { + const resolver = runtime.agent?.session?.resolveSessionFilePath; + if (resolver) return resolver(sessionId, entry, { agentId }); + const agentDir = runtime.agent?.resolveAgentDir?.(runtime.config?.current?.(), agentId); + if (agentDir) return path.join(agentDir, "sessions", `${sessionId}.jsonl`); + return path.join(process.env.OPENCLAW_STATE_DIR ?? path.join(process.env.HOME ?? ".", ".openclaw"), "agents", agentId, "sessions", `${sessionId}.jsonl`); +} + +async function historyFromPluginRuntime(runtime: OpenClawHostRuntime, params: unknown): Promise>> { + const record = recordValue(params) ?? {}; + const sessionKey = stringValue(record.sessionKey) ?? stringValue(record.key); + if (!sessionKey) return []; + const agentId = agentIdFromSessionKey(sessionKey) ?? "main"; + const entry = resolvePluginSession(runtime, sessionKey, agentId).entry; + const sessionId = stringValue(entry?.sessionId); + const sessionFile = stringValue(entry?.sessionFile) ?? (sessionId ? resolvePluginSessionFile(runtime, agentId, sessionId, entry) : undefined); + if (!sessionFile) return []; + const limit = numberValue(record.limit); + const messages = await readHistoryMessages(sessionFile); + return limit && limit > 0 ? messages.slice(-limit) : messages; +} + +async function readHistoryMessages(sessionFile: string): Promise>> { + let raw = ""; + try { + raw = await fs.readFile(sessionFile, "utf8"); + } catch { + return []; + } + const messages: Array> = []; + let seq = 0; + for (const line of raw.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + continue; + } + const message = normalizeHistoryRecord(parsed, ++seq); + if (message) messages.push(message); + } + return messages; +} + +function normalizeHistoryRecord(value: unknown, seq: number): Record | undefined { + const record = recordValue(value); + if (!record) return undefined; + const message = recordValue(record.message) ?? recordValue(record.data) ?? record; + const role = stringValue(message.role) ?? stringValue(record.role); + const content = historyContentText(message.content) ?? stringValue(message.text) ?? stringValue(message.content) ?? stringValue(record.text); + if (!role || !content) return undefined; + return stripUndefined({ + content, + id: stringValue(message.id) ?? stringValue(record.id) ?? `history:${seq}`, + messageSeq: numberValue(record.messageSeq) ?? seq, + role: role === "assistant" ? "agent" : role, + timestamp: numberValue(record.timestamp) ?? numberValue(message.timestamp) ?? numberValue(record.createdAt) ?? numberValue(message.createdAt), + }); +} + +function historyContentText(value: unknown): string | undefined { + if (typeof value === "string") return value; + const content = arrayValue(value); + if (!content) return undefined; + const parts: string[] = []; + for (const part of content) { + const record = recordValue(part); + const text = stringValue(record?.text) ?? stringValue(record?.thinking); + if (text) parts.push(text); + } + return parts.length ? parts.join("") : undefined; +} + +function agentIdsFromPluginConfig(config: unknown): string[] { + const ids = new Set(["main"]); + for (const agent of agentsFromPluginConfig(config)) { + const id = stringValue(agent.id) ?? stringValue(agent.agentId); + if (id) ids.add(id); + } + return [...ids]; +} + +function agentIdFromSessionKey(sessionKey: string): string | undefined { + return /^agent:([^:]+)/.exec(sessionKey)?.[1]; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function firstNonUndefined(...values: unknown[]): unknown { + return values.find((value) => value !== undefined); +} + +function intValue(value: unknown): number | undefined { + const number = numberValue(value); + return number === undefined ? undefined : Math.trunc(number); +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +type StripUndefined = { + [K in keyof T as undefined extends T[K] ? never : K]: T[K]; +} & { + [K in keyof T as undefined extends T[K] ? K : never]?: Exclude; +}; + +function stripUndefined>(value: T): StripUndefined { + for (const key of Object.keys(value)) { + if (value[key] === undefined) delete value[key]; + } + return value as StripUndefined; +} diff --git a/packages/openclaw/src/plugin-entry.ts b/packages/openclaw/src/plugin-entry.ts new file mode 100644 index 0000000..daff394 --- /dev/null +++ b/packages/openclaw/src/plugin-entry.ts @@ -0,0 +1,13 @@ +import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract"; + +export const openClawBeeperPlugin = defineBundledChannelEntry({ + id: "beeper", + name: "Beeper", + description: "Chat with your OpenClaw agents on Beeper.", + importMetaUrl: import.meta.url, + plugin: { specifier: "./setup.js", exportName: "beeperChannelPlugin" }, + runtime: { specifier: "./setup.js", exportName: "setBeeperOpenClawPluginRuntime" }, + secrets: { specifier: "./secret-contract.js", exportName: "channelSecrets" }, +}); + +export default openClawBeeperPlugin; diff --git a/packages/openclaw/src/protocol-coverage.test.ts b/packages/openclaw/src/protocol-coverage.test.ts new file mode 100644 index 0000000..68a3bc8 --- /dev/null +++ b/packages/openclaw/src/protocol-coverage.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + OPENCLAW_BRIDGE_COVERAGE, + OPENCLAW_GATEWAY_COMMON_METHODS, + OPENCLAW_GATEWAY_EVENT_FAMILIES, + OPENCLAW_GATEWAY_METHOD_FAMILIES, +} from "./protocol-coverage"; + +describe("OpenClaw gateway protocol coverage manifest", () => { + it("tracks all upstream gateway method families", () => { + expect(OPENCLAW_GATEWAY_METHOD_FAMILIES).toEqual([ + "system", + "models", + "usage", + "channels", + "messaging", + "talk", + "secrets", + "config", + "update", + "wizard", + "agents", + "tasks", + "artifacts", + "environments", + "sessions", + "device-pairing", + "node-pairing", + "approvals", + "automation", + "skills", + "tools", + ]); + }); + + it("declares stream, approval, and operational event handling buckets", () => { + const coveredEvents = new Set([ + ...OPENCLAW_BRIDGE_COVERAGE.eventFamilies.stream, + ...OPENCLAW_BRIDGE_COVERAGE.eventFamilies.approval, + ...OPENCLAW_BRIDGE_COVERAGE.eventFamilies.ignoredOperational, + ]); + expect(OPENCLAW_GATEWAY_EVENT_FAMILIES.every((family) => coveredEvents.has(family))).toBe(true); + }); + + it("keeps broad feature access routed through plugin runtime surfaces", () => { + expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.beeperTurnDispatch).toBe("runtime.channel.turn.runAssembled"); + expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.pluginRuntimeAdapters).toEqual(expect.arrayContaining([ + "agents.list", + "sessions.list", + "sessions.create", + "chat.history", + "exec.approval.resolve", + "plugin.approval.resolve", + ])); + expect(OPENCLAW_GATEWAY_COMMON_METHODS).toEqual(expect.arrayContaining([ + "talk.session.create", + "config.schema.lookup", + "agents.files.set", + "sessions.messages.subscribe", + "device.token.rotate", + "node.pending.enqueue", + "plugin.approval.resolve", + "skills.install", + "tools.invoke", + ])); + expect(new Set(OPENCLAW_GATEWAY_COMMON_METHODS).size).toBe(OPENCLAW_GATEWAY_COMMON_METHODS.length); + }); +}); diff --git a/packages/openclaw/src/protocol-coverage.ts b/packages/openclaw/src/protocol-coverage.ts new file mode 100644 index 0000000..f36e404 --- /dev/null +++ b/packages/openclaw/src/protocol-coverage.ts @@ -0,0 +1,223 @@ +export const OPENCLAW_GATEWAY_METHOD_FAMILIES = [ + "system", + "models", + "usage", + "channels", + "messaging", + "talk", + "secrets", + "config", + "update", + "wizard", + "agents", + "tasks", + "artifacts", + "environments", + "sessions", + "device-pairing", + "node-pairing", + "approvals", + "automation", + "skills", + "tools", +] as const; + +export const OPENCLAW_GATEWAY_COMMON_METHODS = [ + "health", + "diagnostics.stability", + "status", + "gateway.identity.get", + "system-presence", + "system-event", + "last-heartbeat", + "set-heartbeats", + "models.list", + "usage.status", + "usage.cost", + "doctor.memory.status", + "doctor.memory.remHarness", + "sessions.usage", + "sessions.usage.timeseries", + "sessions.usage.logs", + "channels.status", + "channels.logout", + "web.login.start", + "web.login.wait", + "push.test", + "voicewake.get", + "voicewake.set", + "send", + "logs.tail", + "talk.catalog", + "talk.config", + "talk.session.create", + "talk.session.join", + "talk.session.appendAudio", + "talk.session.startTurn", + "talk.session.endTurn", + "talk.session.cancelTurn", + "talk.session.cancelOutput", + "talk.session.submitToolResult", + "talk.session.close", + "talk.mode", + "talk.client.create", + "talk.client.toolCall", + "talk.event", + "talk.speak", + "tts.status", + "tts.providers", + "tts.enable", + "tts.disable", + "tts.setProvider", + "tts.convert", + "secrets.reload", + "secrets.resolve", + "config.get", + "config.set", + "config.patch", + "config.apply", + "config.schema", + "config.schema.lookup", + "update.run", + "update.status", + "wizard.start", + "wizard.next", + "wizard.status", + "wizard.cancel", + "agents.list", + "agents.create", + "agents.update", + "agents.delete", + "agents.files.list", + "agents.files.get", + "agents.files.set", + "tasks.list", + "tasks.get", + "tasks.cancel", + "artifacts.list", + "artifacts.get", + "artifacts.download", + "environments.list", + "environments.status", + "agent.identity.get", + "agent.wait", + "sessions.list", + "sessions.subscribe", + "sessions.unsubscribe", + "sessions.messages.subscribe", + "sessions.messages.unsubscribe", + "sessions.preview", + "sessions.describe", + "sessions.resolve", + "sessions.create", + "sessions.steer", + "sessions.abort", + "sessions.patch", + "sessions.reset", + "sessions.delete", + "sessions.compact", + "sessions.get", + "chat.history", + "chat.send", + "chat.abort", + "chat.inject", + "device.pair.list", + "device.pair.approve", + "device.pair.reject", + "device.pair.remove", + "device.token.rotate", + "device.token.revoke", + "node.pair.request", + "node.pair.list", + "node.pair.approve", + "node.pair.reject", + "node.pair.remove", + "node.pair.verify", + "node.list", + "node.describe", + "node.rename", + "node.invoke", + "node.invoke.result", + "node.event", + "node.pending.pull", + "node.pending.ack", + "node.pending.enqueue", + "node.pending.drain", + "exec.approval.request", + "exec.approval.get", + "exec.approval.list", + "exec.approval.resolve", + "exec.approval.waitDecision", + "exec.approvals.get", + "exec.approvals.set", + "exec.approvals.node.get", + "exec.approvals.node.set", + "plugin.approval.request", + "plugin.approval.list", + "plugin.approval.waitDecision", + "plugin.approval.resolve", + "wake", + "cron.get", + "cron.list", + "cron.status", + "cron.add", + "cron.update", + "cron.remove", + "cron.run", + "cron.runs", + "commands.list", + "skills.status", + "skills.search", + "skills.detail", + "skills.bins", + "skills.upload.begin", + "skills.upload.chunk", + "skills.upload.commit", + "skills.install", + "skills.update", + "tools.catalog", + "tools.effective", + "tools.invoke", +] as const; + +export const OPENCLAW_GATEWAY_EVENT_FAMILIES = [ + "chat", + "session.message", + "session.operation", + "session.tool", + "sessions.changed", + "presence", + "tick", + "health", + "heartbeat", + "cron", + "shutdown", + "node.pair.requested", + "node.pair.resolved", + "node.invoke.request", + "device.pair.requested", + "device.pair.resolved", + "voicewake.changed", + "exec.approval.requested", + "exec.approval.resolved", + "plugin.approval.requested", + "plugin.approval.resolved", +] as const; + +export const OPENCLAW_BRIDGE_COVERAGE = { + eventFamilies: { + approval: ["exec.approval.requested", "exec.approval.resolved", "plugin.approval.requested", "plugin.approval.resolved"], + ignoredOperational: ["sessions.changed", "presence", "tick", "health", "heartbeat", "cron", "shutdown", "node.pair.requested", "node.pair.resolved", "node.invoke.request", "device.pair.requested", "device.pair.resolved", "voicewake.changed"], + stream: ["chat", "session.message", "session.operation", "session.tool"], + }, + methodAccess: { + pluginRuntimeAdapters: ["agents.list", "sessions.list", "sessions.create", "sessions.patch", "chat.history", "exec.approval.resolve", "plugin.approval.resolve"], + commonGatewayMethods: OPENCLAW_GATEWAY_COMMON_METHODS, + beeperTurnDispatch: "runtime.channel.turn.runAssembled", + }, + source: ".upstream/openclaw/docs/gateway/protocol.md", +} as const; + +export type OpenClawGatewayMethodFamily = typeof OPENCLAW_GATEWAY_METHOD_FAMILIES[number]; +export type OpenClawGatewayCommonMethod = typeof OPENCLAW_GATEWAY_COMMON_METHODS[number]; +export type OpenClawGatewayEventFamily = typeof OPENCLAW_GATEWAY_EVENT_FAMILIES[number]; diff --git a/packages/openclaw/src/registration.test.ts b/packages/openclaw/src/registration.test.ts new file mode 100644 index 0000000..d31433e --- /dev/null +++ b/packages/openclaw/src/registration.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { createDefaultConfig } from "./config"; +import { + createAppserviceRegistration, + openClawAgentGhostLocalpart, + openClawAliasLocalpart, + openClawRoomCreationPreset, +} from "./registration"; + +describe("OpenClaw appservice registration", () => { + it("reserves bridge bot and OpenClaw agent namespaces", () => { + const config = createDefaultConfig({ + appserviceId: "sh-openclaw-device", + bridgeId: "sh-openclaw-device", + dataDir: "/tmp/openclaw", + homeserverDomain: "beeper.local", + }); + const registration = createAppserviceRegistration(config, { asToken: "as", hsToken: "hs" }); + expect(registration).toMatchObject({ + as_token: "as", + hs_token: "hs", + id: "sh-openclaw-device", + rate_limited: false, + receive_ephemeral: true, + sender_localpart: "sh-openclaw-devicebot", + url: "websocket", + }); + expect(registration.namespaces.users).toEqual([ + { exclusive: true, regex: "^@sh-openclaw-device_agent_.+:beeper\\.local$" }, + { exclusive: true, regex: "^@sh-openclaw-devicebot:beeper\\.local$" }, + ]); + expect(registration.namespaces.aliases).toEqual([ + { exclusive: true, regex: "^#sh-openclaw-device_.+:.*$" }, + ]); + }); + + it("derives Matrix-safe localparts and non-federated room presets", () => { + const config = createDefaultConfig({ dataDir: "/tmp/openclaw" }); + expect(openClawAgentGhostLocalpart(config, "Codex/Main Agent")).toBe("sh-openclaw_agent_codex/main_agent"); + expect(openClawAliasLocalpart(config, "session 1")).toBe("sh-openclaw_session_1"); + expect(openClawRoomCreationPreset(config)).toEqual({ + creation_content: { "m.federate": false }, + preset: "private_chat", + }); + }); + + it("uses appservice tokens without Matrix user credentials", () => { + const config = createDefaultConfig({ + asToken: "as-token", + dataDir: "/tmp/openclaw", + hsToken: "hs-token", + }); + expect(createAppserviceRegistration(config).as_token).toBe("as-token"); + expect(createAppserviceRegistration(config).hs_token).toBe("hs-token"); + + const generated = createAppserviceRegistration(createDefaultConfig({ + dataDir: "/tmp/openclaw", + })); + expect(generated.as_token).toMatch(/^[a-f0-9]{64}$/u); + }); +}); diff --git a/packages/openclaw/src/registration.ts b/packages/openclaw/src/registration.ts new file mode 100644 index 0000000..0a77663 --- /dev/null +++ b/packages/openclaw/src/registration.ts @@ -0,0 +1,80 @@ +import { secretToken } from "./config"; +import type { AppserviceRegistration, OpenClawBridgeConfig } from "./types"; + +export interface CreateRegistrationOptions { + asToken?: string; + hsToken?: string; +} + +export function createAppserviceRegistration( + config: OpenClawBridgeConfig, + options: CreateRegistrationOptions = {} +): AppserviceRegistration { + const domain = escapeRegex(config.homeserverDomain ?? matrixDomainFromHomeserver(config.homeserver)); + const ghostPrefix = escapeRegex(openClawAgentGhostPrefix(config)); + const senderLocalpart = openClawSenderLocalpart(config); + const sender = escapeRegex(senderLocalpart); + return { + as_token: options.asToken ?? config.asToken ?? secretToken(), + hs_token: options.hsToken ?? config.hsToken ?? secretToken(), + id: config.appserviceId, + namespaces: { + aliases: [{ exclusive: true, regex: `^#${escapeRegex(config.appserviceId)}_.+:.*$` }], + rooms: [], + users: [ + { exclusive: true, regex: `^@${ghostPrefix}.+:${domain}$` }, + { exclusive: true, regex: `^@${sender}:${domain}$` }, + ], + }, + receive_ephemeral: true, + rate_limited: false, + sender_localpart: senderLocalpart, + url: "websocket", + }; +} + +function matrixDomainFromHomeserver(homeserver: string | undefined): string { + if (!homeserver) return "localhost"; + try { + return new URL(homeserver).hostname; + } catch { + return homeserver.replace(/^https?:\/\//, "").split("/")[0] || "localhost"; + } +} + +export function openClawAgentGhostLocalpart(config: OpenClawBridgeConfig, agentId: string): string { + return `${openClawAgentGhostPrefix(config)}${encodeLocalpartSegment(agentId)}`; +} + +export function openClawAliasLocalpart(config: OpenClawBridgeConfig, roomKey: string): string { + return `${config.appserviceId}_${encodeLocalpartSegment(roomKey)}`; +} + +export function openClawRoomCreationPreset(config: OpenClawBridgeConfig): Record { + return { + creation_content: { + "m.federate": false, + }, + preset: "private_chat", + }; +} + +export function openClawBridgeId(config: OpenClawBridgeConfig): string { + return config.bridgeId ?? config.appserviceId; +} + +export function openClawAgentGhostPrefix(config: OpenClawBridgeConfig): string { + return `${openClawBridgeId(config)}_agent_`; +} + +export function openClawSenderLocalpart(config: OpenClawBridgeConfig): string { + return `${openClawBridgeId(config)}bot`; +} + +function encodeLocalpartSegment(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9=_./-]+/g, "_").replace(/^_+|_+$/g, "") || "default"; +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/openclaw/src/registry.test.ts b/packages/openclaw/src/registry.test.ts new file mode 100644 index 0000000..fbccd8e --- /dev/null +++ b/packages/openclaw/src/registry.test.ts @@ -0,0 +1,45 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { OpenClawBridgeRegistry } from "./registry"; + +describe("OpenClawBridgeRegistry", () => { + const cleanup: string[] = []; + + afterEach(async () => { + await Promise.all(cleanup.splice(0).map((dir) => rm(dir, { force: true, recursive: true }))); + }); + + it("persists agent contacts, session bindings, and dedupe keys", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-")); + cleanup.push(dir); + const path = resolve(dir, "registry.json"); + const registry = new OpenClawBridgeRegistry(path); + await registry.load(); + registry.upsertAgent({ + agentId: "codex", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:example.com", + }); + registry.upsertBinding({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@sh-openclaw_agent_codex:example.com", + id: "binding", + roomId: "!room:example.com", + sessionKey: "agent:codex:main", + updatedAt: 1, + }); + registry.markDedupe("$event"); + await registry.save(); + + const loaded = new OpenClawBridgeRegistry(path); + await loaded.load(); + expect(loaded.getAgent("codex")?.displayName).toBe("Codex"); + expect(loaded.getBindingByRoom("!room:example.com")?.sessionKey).toBe("agent:codex:main"); + expect(loaded.getBindingBySessionKey("agent:codex:main")?.id).toBe("binding"); + expect(loaded.getBindingsByAgent("codex")).toHaveLength(1); + expect(loaded.hasDedupe("$event")).toBe(true); + }); +}); diff --git a/packages/openclaw/src/registry.ts b/packages/openclaw/src/registry.ts new file mode 100644 index 0000000..e5095f9 --- /dev/null +++ b/packages/openclaw/src/registry.ts @@ -0,0 +1,138 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { defaultDataDir } from "./config"; +import type { OpenClawAgentContact, OpenClawBridgeRegistryData, OpenClawSessionBinding } from "./types"; + +export function defaultRegistryPath(dataDir = defaultDataDir()): string { + return resolve(dataDir, "registry.json"); +} + +export function emptyRegistry(): OpenClawBridgeRegistryData { + return { agents: [], bindings: [], dedupe: {}, schemaVersion: 1 }; +} + +export class OpenClawBridgeRegistry { + readonly path: string; + #data: OpenClawBridgeRegistryData = emptyRegistry(); + + constructor(path = defaultRegistryPath()) { + this.path = path; + } + + get data(): OpenClawBridgeRegistryData { + return structuredClone(this.#data); + } + + async load(): Promise { + try { + this.#data = normalizeRegistry(JSON.parse(await readFile(this.path, "utf8"))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + this.#data = emptyRegistry(); + } + } + + async save(): Promise { + await mkdir(dirname(this.path), { recursive: true }); + const tmp = `${this.path}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(tmp, `${JSON.stringify(this.#data, null, 2)}\n`, { mode: 0o600 }); + await rename(tmp, this.path); + } + + getAgent(agentId: string): OpenClawAgentContact | undefined { + return this.#data.agents.find((agent) => agent.agentId === agentId); + } + + upsertAgent(agent: OpenClawAgentContact): void { + const index = this.#data.agents.findIndex((item) => item.agentId === agent.agentId); + if (index === -1) this.#data.agents.push(agent); + else this.#data.agents[index] = agent; + } + + replaceAgents(agents: OpenClawAgentContact[]): void { + this.#data.agents = [...agents]; + } + + getBindingById(id: string): OpenClawSessionBinding | undefined { + return this.#data.bindings.find((binding) => binding.id === id); + } + + getBindingByRoom(roomId: string): OpenClawSessionBinding | undefined { + return this.#data.bindings.find((binding) => binding.roomId === roomId); + } + + getBindingBySessionKey(sessionKey: string): OpenClawSessionBinding | undefined { + return this.#data.bindings.find((binding) => binding.sessionKey === sessionKey); + } + + getBindingsByAgent(agentId: string): OpenClawSessionBinding[] { + return this.#data.bindings.filter((binding) => binding.agentId === agentId); + } + + upsertBinding(binding: OpenClawSessionBinding): void { + const index = this.#data.bindings.findIndex((item) => item.id === binding.id); + if (index === -1) this.#data.bindings.push(normalizeBinding(binding)); + else this.#data.bindings[index] = normalizeBinding(binding); + } + + updateBinding( + id: string, + update: (binding: OpenClawSessionBinding) => OpenClawSessionBinding + ): OpenClawSessionBinding | undefined { + const index = this.#data.bindings.findIndex((item) => item.id === id); + const existing = this.#data.bindings[index]; + if (index === -1 || !existing) return undefined; + const updated = update(existing); + this.#data.bindings[index] = updated; + return updated; + } + + removeBindingByRoom(roomId: string): OpenClawSessionBinding | undefined { + const index = this.#data.bindings.findIndex((binding) => binding.roomId === roomId); + const existing = this.#data.bindings[index]; + if (index === -1 || !existing) return undefined; + this.#data.bindings.splice(index, 1); + return existing; + } + + markDedupe(key: string, timestamp = Date.now()): void { + this.#data.dedupe[key] = timestamp; + } + + hasDedupe(key: string): boolean { + return this.#data.dedupe[key] !== undefined; + } +} + +function normalizeRegistry(value: unknown): OpenClawBridgeRegistryData { + if (!value || typeof value !== "object") return emptyRegistry(); + const data = value as Partial; + return { + agents: Array.isArray(data.agents) ? data.agents : [], + bindings: Array.isArray(data.bindings) ? data.bindings.map(normalizeBinding).filter(Boolean) : [], + dedupe: data.dedupe && typeof data.dedupe === "object" ? data.dedupe : {}, + schemaVersion: 1, + }; +} + +function normalizeBinding(value: unknown): OpenClawSessionBinding { + const binding = value as OpenClawSessionBinding; + return { + agentId: binding.agentId, + createdAt: binding.createdAt, + ghostUserId: binding.ghostUserId, + id: binding.id, + ...(binding.cwd ? { cwd: binding.cwd } : {}), + ...(binding.humanGhostUserId ? { humanGhostUserId: binding.humanGhostUserId } : {}), + ...(binding.label ? { label: binding.label } : {}), + ...(binding.lastMatrixEventId ? { lastMatrixEventId: binding.lastMatrixEventId } : {}), + ...(binding.lastRunId ? { lastRunId: binding.lastRunId } : {}), + ...(binding.lastStreamRunId ? { lastStreamRunId: binding.lastStreamRunId } : {}), + ...(binding.lastStreamTargetEventId ? { lastStreamTargetEventId: binding.lastStreamTargetEventId } : {}), + roomId: binding.roomId, + ...(binding.sessionKey ? { sessionKey: binding.sessionKey } : {}), + ...(binding.spaceId ? { spaceId: binding.spaceId } : {}), + updatedAt: binding.updatedAt, + }; +} diff --git a/packages/openclaw/src/rooms.test.ts b/packages/openclaw/src/rooms.test.ts new file mode 100644 index 0000000..59caed0 --- /dev/null +++ b/packages/openclaw/src/rooms.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createDefaultConfig } from "./config"; +import { + agentContactFromOpenClawAgent, + agentGhostUserId, + matrixDomainFromHomeserver, + serviceBotUserId, +} from "./rooms"; + +describe("OpenClaw room and contact helpers", () => { + it("derives ghost identities for every OpenClaw agent", () => { + const config = createDefaultConfig({ dataDir: "/tmp/openclaw", homeserver: "https://matrix.example.com" }); + expect(matrixDomainFromHomeserver(config.homeserver)).toBe("matrix.example.com"); + expect(agentGhostUserId(config, "Codex Main")).toBe("@sh-openclaw_agent_codex_main:matrix.example.com"); + expect(serviceBotUserId(config)).toBe("@sh-openclawbot:matrix.example.com"); + expect(agentContactFromOpenClawAgent(config, { + avatarMxc: "mxc://example/avatar", + description: "Local code agent", + id: "codex", + name: "Codex", + })).toEqual({ + agentId: "codex", + avatarMxc: "mxc://example/avatar", + avatarUrl: "mxc://example/avatar", + description: "Local code agent", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:matrix.example.com", + }); + }); + +}); diff --git a/packages/openclaw/src/rooms.ts b/packages/openclaw/src/rooms.ts new file mode 100644 index 0000000..6d7c628 --- /dev/null +++ b/packages/openclaw/src/rooms.ts @@ -0,0 +1,57 @@ +import type { OpenClawAgentContact, OpenClawBridgeConfig } from "./types"; +import { openClawAgentGhostLocalpart, openClawSenderLocalpart } from "./registration"; + +export function bindingIdForRoom(roomId: string): string { + return Buffer.from(roomId).toString("base64url"); +} + +export function matrixDomainFromHomeserver(homeserver: string | undefined): string { + if (!homeserver) return "localhost"; + try { + return new URL(homeserver).hostname; + } catch { + return homeserver.replace(/^https?:\/\//, "").split("/")[0] || "localhost"; + } +} + +function matrixDomainFromConfig(config: OpenClawBridgeConfig): string { + return config.homeserverDomain ?? matrixDomainFromHomeserver(config.homeserver); +} + +export function agentGhostUserId(config: OpenClawBridgeConfig, agentId: string, domain = matrixDomainFromConfig(config)): string { + return `@${openClawAgentGhostLocalpart(config, agentId)}:${domain}`; +} + +export function serviceBotUserId(config: OpenClawBridgeConfig, domain = matrixDomainFromConfig(config)): string { + return `@${openClawSenderLocalpart(config)}:${domain}`; +} + +export function agentContactFromOpenClawAgent( + config: OpenClawBridgeConfig, + agent: Record, + domain = matrixDomainFromConfig(config) +): OpenClawAgentContact { + const agentId = stringValue(agent.id) ?? stringValue(agent.agentId) ?? stringValue(agent.name) ?? "default"; + const displayName = stringValue(agent.displayName) ?? stringValue(agent.name) ?? agentId; + const contact: OpenClawAgentContact = { + agentId, + displayName, + ghostUserId: agentGhostUserId(config, agentId, domain), + }; + const rawAvatarUrl = stringValue(agent.avatarUrl) ?? stringValue(agent.avatar_url) ?? stringValue(agent.avatar); + const avatarMxc = stringValue(agent.avatarMxc) ?? mxcAvatarURL(rawAvatarUrl); + const description = stringValue(agent.description); + if (avatarMxc) contact.avatarMxc = avatarMxc; + const avatarUrl = rawAvatarUrl ?? avatarMxc; + if (avatarUrl) contact.avatarUrl = avatarUrl; + if (description) contact.description = description; + return contact; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function mxcAvatarURL(value: string | undefined): string | undefined { + return value?.startsWith("mxc://") ? value : undefined; +} diff --git a/packages/openclaw/src/secret-contract.ts b/packages/openclaw/src/secret-contract.ts new file mode 100644 index 0000000..4e830c6 --- /dev/null +++ b/packages/openclaw/src/secret-contract.ts @@ -0,0 +1,78 @@ +import { + collectSecretInputAssignment, + getChannelSurface, + type ResolverContext, + type SecretDefaults, + type SecretTargetRegistryEntry, +} from "openclaw/plugin-sdk/channel-secret-basic-runtime"; + +export const secretTargetRegistryEntries: SecretTargetRegistryEntry[] = [ + { + id: "channels.beeper.accounts.*.asToken", + targetType: "channels.beeper.accounts.*.asToken", + configFile: "openclaw.json", + pathPattern: "channels.beeper.accounts.*.asToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.beeper.accounts.*.hsToken", + targetType: "channels.beeper.accounts.*.hsToken", + configFile: "openclaw.json", + pathPattern: "channels.beeper.accounts.*.hsToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, +]; + +export function collectRuntimeConfigAssignments(params: { + config: { channels?: Record }; + defaults?: SecretDefaults; + context: ResolverContext; +}): void { + const resolved = getChannelSurface(params.config, "beeper"); + if (!resolved) return; + const { channel, surface } = resolved; + const accounts = recordValue(channel.accounts); + if (!accounts) return; + for (const [accountId, value] of Object.entries(accounts)) { + const account = recordValue(value); + if (!account) continue; + const accountEnabled = surface.channelEnabled && account.enabled !== false; + for (const field of ["asToken", "hsToken"] as const) { + const assignment = { + value: account[field], + path: `channels.beeper.accounts.${accountId}.${field}`, + expected: "string" as const, + defaults: params.defaults, + context: params.context, + active: accountEnabled, + apply: (nextValue: unknown) => { + account[field] = nextValue; + }, + }; + if (!accountEnabled) { + Object.assign(assignment, { inactiveReason: `Beeper account "${accountId}" is disabled.` }); + } + collectSecretInputAssignment({ + ...assignment, + }); + } + } +} + +function recordValue(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value as Record; +} + +export const channelSecrets = { + secretTargetRegistryEntries, + collectRuntimeConfigAssignments, +}; diff --git a/packages/openclaw/src/serial.ts b/packages/openclaw/src/serial.ts new file mode 100644 index 0000000..42428b7 --- /dev/null +++ b/packages/openclaw/src/serial.ts @@ -0,0 +1,9 @@ +export class SerialQueue { + #tail = Promise.resolve(); + + run(operation: () => Promise): Promise { + const next = this.#tail.then(operation, operation); + this.#tail = next.then(() => undefined, () => undefined); + return next; + } +} diff --git a/packages/openclaw/src/setup-entry.ts b/packages/openclaw/src/setup-entry.ts new file mode 100644 index 0000000..8328995 --- /dev/null +++ b/packages/openclaw/src/setup-entry.ts @@ -0,0 +1,10 @@ +import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract"; + +export const openClawBeeperSetupEntry = defineBundledChannelSetupEntry({ + importMetaUrl: import.meta.url, + plugin: { specifier: "./setup.js", exportName: "beeperChannelPlugin" }, + runtime: { specifier: "./setup.js", exportName: "setBeeperOpenClawPluginRuntime" }, + secrets: { specifier: "./secret-contract.js", exportName: "channelSecrets" }, +}); + +export default openClawBeeperSetupEntry; diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts new file mode 100644 index 0000000..f4d39b1 --- /dev/null +++ b/packages/openclaw/src/setup.test.ts @@ -0,0 +1,1216 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import extension from "./plugin-entry"; +import setupEntry from "./setup-entry"; +import { + BeeperChannelRuntime, + setBeeperChannelRuntimeForHost, +} from "./beeper-channel-runtime"; +import { BeeperTurnStream } from "@beeper/pickle-bridge/beeper-stream"; +import { + applyBeeperChannelSettings, + applyBeeperAccountSettings, + beeperChannelConfig, + beeperChannelPlugin, + beeperStatusAdapter, + beeperSetupAdapter, + beeperSetupWizard, + defaultBeeperAccountSettings, + getBeeperAccountSettings, + getBeeperChannelSettings, + isBeeperChannelConfigured, + listBeeperAccountIds, + beeperAccountIdFromMatrixUserId, + resolveBeeperAgentAccountId, + resolveDefaultBeeperAccountId, + setBeeperOpenClawPluginRuntime, + startBeeperGatewayAccount, + validateBeeperSetupInput, +} from "./setup"; +import { createConfigFromOpenClawSetup } from "./config"; + +const appserviceMocks = vi.hoisted(() => ({ + startOpenClawBeeperBridge: vi.fn(), +})); + +vi.mock("./appservice", () => appserviceMocks); + +describe("OpenClaw Beeper official channel contracts", () => { + it("satisfies the base channel plugin contract", () => { + expect(typeof beeperChannelPlugin.id).toBe("string"); + expect(beeperChannelPlugin.id.trim()).not.toBe(""); + expect(beeperChannelPlugin.meta.id).toBe(beeperChannelPlugin.id); + expect(beeperChannelPlugin.meta.label.trim()).not.toBe(""); + expect(beeperChannelPlugin.meta.selectionLabel.trim()).not.toBe(""); + expect(beeperChannelPlugin.meta.docsPath).toMatch(/^\/channels\//); + expect(beeperChannelPlugin.meta.blurb.trim()).not.toBe(""); + expect(beeperChannelPlugin.capabilities.chatTypes.length).toBeGreaterThan(0); + expect(typeof beeperChannelPlugin.config.listAccountIds).toBe("function"); + expect(typeof beeperChannelPlugin.config.resolveAccount).toBe("function"); + }); + + it("exposes the base message actions contract", () => { + expect(beeperChannelPlugin.actions).toBeDefined(); + expect(typeof beeperChannelPlugin.actions?.describeMessageTool).toBe("function"); + }); + + it("actions contract: default Beeper message actions", () => { + const discovery = beeperChannelPlugin.actions?.describeMessageTool({ cfg: {} }) ?? null; + const actions = Array.isArray(discovery?.actions) ? [...discovery.actions] : []; + const capabilities = Array.isArray(discovery?.capabilities) ? discovery.capabilities : []; + expect(actions).toEqual([...new Set(actions)]); + expect(capabilities).toEqual([...new Set(capabilities)]); + expect([...actions].sort()).toEqual([ + "channel-edit", + "channel-info", + "delete", + "edit", + "mark_unread", + "react", + "read", + "send", + ]); + expect([...capabilities].sort()).toEqual([]); + for (const action of [ + "channel-edit", + "channel-info", + "delete", + "edit", + "mark_unread", + "react", + "read", + "send", + ] as const) { + expect(beeperChannelPlugin.actions?.supportsAction?.({ action })).toBe(true); + } + }); + + it("exposes the base setup contract", () => { + expect(beeperChannelPlugin.setup).toBeDefined(); + expect(typeof beeperChannelPlugin.setup?.applyAccountConfig).toBe("function"); + }); + + it("setup contract: non-login setup environment patch", () => { + const resolvedAccountId = + beeperChannelPlugin.setup?.resolveAccountId?.({ + cfg: {}, + accountId: "@alice:beeper.com", + input: { serverEnv: "staging" }, + }) ?? "@alice:beeper.com"; + expect(resolvedAccountId).toBe("@alice:beeper.com"); + expect(beeperChannelPlugin.setup?.validateInput?.({ + accountId: "@alice:beeper.com", + cfg: {}, + input: { + serverEnv: "staging", + }, + }) ?? null).toBeNull(); + + const cfg = beeperChannelPlugin.setup?.applyAccountConfig({ + accountId: "@alice:beeper.com", + cfg: {}, + input: { + serverEnv: "staging", + }, + }); + expect(cfg).toBeDefined(); + expect(getBeeperAccountSettings(cfg!, "@alice:beeper.com")).toMatchObject({ + serverEnv: "staging", + enabled: true, + }); + expect(beeperChannelPlugin.config.resolveAccount(cfg!, "@alice:beeper.com")).toMatchObject({ + accountId: "@alice:beeper.com", + configured: false, + }); + }); + + it("exposes the base status contract", () => { + expect(beeperChannelPlugin.status).toBeDefined(); + expect(typeof beeperChannelPlugin.status?.buildAccountSnapshot).toBe("function"); + }); + + it("status contract: configured account", async () => { + const cfg = applyBeeperAccountSettings({}, "@alice:beeper.com", { + enabled: true, + asToken: "as", + hsToken: "hs", + bridge: { + homeserver: "https://matrix.example", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + }); + const account = beeperChannelPlugin.config.resolveAccount(cfg, "@alice:beeper.com"); + const snapshot = await beeperChannelPlugin.status!.buildAccountSnapshot!({ + account, + cfg, + runtime: { accountId: "@alice:beeper.com", configured: true, enabled: true, running: true }, + }); + expect(snapshot).toMatchObject({ + accountId: "@alice:beeper.com", + configured: true, + enabled: true, + name: "Beeper", + running: true, + }); + expect(beeperChannelPlugin.status!.buildChannelSummary!({ + account, + cfg, + defaultAccountId: "@alice:beeper.com", + snapshot, + })).toMatchObject({ + configured: true, + enabled: true, + running: true, + }); + expect(beeperChannelPlugin.status!.resolveAccountState!({ + account, + cfg, + configured: true, + enabled: true, + })).toBe("configured"); + }); + + it("status contract: disabled account", () => { + const cfg = applyBeeperAccountSettings({}, "@alice:beeper.com", { enabled: false }); + const account = beeperChannelPlugin.config.resolveAccount(cfg, "@alice:beeper.com"); + expect(beeperChannelPlugin.status!.resolveAccountState!({ + account, + cfg, + configured: false, + enabled: false, + })).toBe("disabled"); + }); +}); + +describe("OpenClaw Beeper setup surface", () => { + beforeEach(() => { + appserviceMocks.startOpenClawBeeperBridge.mockReset(); + setBeeperOpenClawPluginRuntime(undefined); + }); + + it("exposes a channel plugin through the setup entry shape OpenClaw loads", () => { + expect(extension.loadChannelPlugin().id).toBe("beeper"); + expect(beeperChannelPlugin.id).toBe("beeper"); + expect(beeperChannelPlugin.meta.id).toBe("beeper"); + expect(beeperChannelPlugin.meta.label).toBe("Beeper"); + expect(beeperChannelPlugin.capabilities.media).toBe(true); + expect(beeperChannelPlugin.capabilities.nativeCommands).toBe(true); + expect(beeperChannelPlugin.capabilities.reactions).toBe(true); + expect(beeperChannelPlugin.capabilities.threads).toBe(true); + expect(beeperChannelPlugin.threading).toEqual(expect.any(Object)); + expect(beeperChannelPlugin.reload?.configPrefixes).toEqual(["channels.beeper"]); + expect(beeperChannelPlugin.gateway?.startAccount).toEqual(expect.any(Function)); + expect(beeperChannelPlugin.gateway?.stopAccount).toEqual(expect.any(Function)); + expect(beeperChannelPlugin.uiHints["accounts.*.asToken"]).toEqual( + expect.objectContaining({ sensitive: true, tags: ["hidden"] }), + ); + expect(beeperChannelPlugin.uiHints["accounts.*.hsToken"]).toEqual( + expect.objectContaining({ sensitive: true, tags: ["hidden"] }), + ); + expect(beeperChannelPlugin.uiHints["accounts.*.serverEnv"]).toEqual(expect.objectContaining({ + help: expect.stringContaining("Choose before Beeper login"), + })); + expect(beeperChannelPlugin.setup).toBe(beeperSetupAdapter); + expect(beeperChannelPlugin.setupWizard).toBe(beeperSetupWizard); + }, 60_000); + + it("matches the OpenClaw channel contract surface used by the dashboard and runtime", async () => { + expect(beeperChannelPlugin.id).toBe("beeper"); + expect(beeperChannelPlugin.meta).toEqual(expect.objectContaining({ + blurb: expect.any(String), + docsPath: "/channels/beeper", + id: "beeper", + label: "Beeper", + selectionLabel: expect.any(String), + })); + expect(beeperChannelPlugin.meta).not.toHaveProperty("quickstartAllowFrom"); + expect(beeperChannelPlugin.capabilities.chatTypes).toEqual(["direct", "thread"]); + expect(beeperChannelPlugin.message).toEqual(expect.objectContaining({ + durableFinal: expect.objectContaining({ + capabilities: expect.objectContaining({ + media: true, + messageSendingHooks: true, + replyTo: true, + text: true, + thread: true, + }), + }), + live: expect.objectContaining({ + capabilities: expect.objectContaining({ + nativeStreaming: true, + previewFinalization: true, + progressUpdates: true, + quietFinalization: true, + }), + }), + send: expect.objectContaining({ + media: expect.any(Function), + payload: expect.any(Function), + text: expect.any(Function), + }), + })); + expect(beeperChannelPlugin.outbound).toEqual(expect.objectContaining({ + deliveryMode: "direct", + sendMedia: expect.any(Function), + sendPayload: expect.any(Function), + sendText: expect.any(Function), + })); + expect(beeperChannelPlugin.messaging).toEqual(expect.objectContaining({ + defaultMarkdownTableMode: "bullets", + normalizeTarget: expect.any(Function), + resolveOutboundSessionRoute: expect.any(Function), + targetPrefixes: ["beeper", "agent", "openclaw"], + })); + expect(beeperChannelPlugin.messaging.normalizeTarget("openclaw:codex")).toBe("codex"); + await expect(beeperChannelPlugin.messaging.targetResolver.resolveTarget({ + cfg: {} as OpenClawSetupConfig, + input: "agent:codex", + normalized: "agent:codex", + })).resolves.toMatchObject({ + display: "@codex", + kind: "user", + source: "normalized", + to: "codex", + }); + expect(beeperChannelPlugin.conversationBindings).toEqual(expect.objectContaining({ + buildBoundReplyPayload: expect.any(Function), + defaultTopLevelPlacement: "current", + supportsCurrentConversationBinding: true, + })); + expect(beeperChannelPlugin.directory).toEqual(expect.objectContaining({ + listPeers: expect.any(Function), + })); + await expect(beeperChannelPlugin.directory.listPeers({ + cfg: { + agents: { + list: [ + { id: "codex", name: "Codex" }, + { id: "planner", name: "Planner" }, + ], + }, + } as unknown as OpenClawSetupConfig, + query: "code", + })).resolves.toEqual([{ + handle: "codex", + id: "codex", + kind: "user", + name: "Codex", + raw: { id: "codex", name: "Codex" }, + }]); + await expect(beeperChannelPlugin.resolver.resolveTargets({ + cfg: { + agents: { list: [{ id: "codex", name: "Codex" }] }, + } as unknown as OpenClawSetupConfig, + inputs: ["beeper:codex", "agent:unknown"], + kind: "user", + })).resolves.toEqual([ + { id: "codex", input: "beeper:codex", name: "Codex", resolved: true }, + { id: "unknown", input: "agent:unknown", name: "@unknown", resolved: true }, + ]); + expect(beeperChannelPlugin.heartbeat).toEqual(expect.objectContaining({ + sendTyping: expect.any(Function), + })); + expect(beeperChannelPlugin.approvalCapability).toEqual(expect.any(Object)); + expect(beeperChannelPlugin.approvalCapability.render.exec.buildPendingPayload({ + nowMs: 123, + request: { + approvalId: "approval_1", + command: "shell date", + toolCallId: "tool_1", + toolName: "shell", + }, + })).toMatchObject({ + body: "Approval requested: shell date", + content: { + body: "Approval requested: shell date", + msgtype: "m.notice", + "com.beeper.ai": { + parts: [{ + approval: { + actions: expect.arrayContaining([ + expect.objectContaining({ id: "allow-once", reactionKey: "approval.allow_once" }), + expect.objectContaining({ id: "deny", reactionKey: "approval.deny" }), + ]), + id: "approval_1", + }, + id: "tool_1", + name: "shell", + state: "approval-requested", + toolCallId: "tool_1", + type: "tool-call", + }], + role: "assistant", + }, + }, + }); + expect(beeperChannelPlugin.actions).toEqual(expect.any(Object)); + expect(beeperChannelPlugin.actions.describeMessageTool()).toMatchObject({ + actions: [ + "send", + "edit", + "delete", + "react", + "read", + "mark_unread", + "channel-info", + "channel-edit", + ], + capabilities: [], + }); + expect(beeperChannelPlugin.actions.extractToolSend({ + args: { action: "send", threadId: "$thread", to: "beeper:!room" }, + })).toBeNull(); + expect(beeperChannelPlugin.agentPrompt).toEqual(expect.objectContaining({ + inboundFormattingHints: expect.any(Function), + messageToolCapabilities: expect.any(Function), + reactionGuidance: expect.any(Function), + })); + expect(beeperChannelPlugin.agentPrompt.messageToolCapabilities()).toEqual(["reactions"]); + expect(beeperChannelPlugin.config).toEqual(expect.objectContaining({ + describeAccount: expect.any(Function), + hasConfiguredState: expect.any(Function), + isConfigured: expect.any(Function), + isEnabled: expect.any(Function), + listAccountIds: expect.any(Function), + resolveAccount: expect.any(Function), + })); + expect(beeperChannelPlugin.setup).toEqual(expect.objectContaining({ + applyAccountConfig: expect.any(Function), + applyAccountName: expect.any(Function), + resolveAccountId: expect.any(Function), + resolveBindingAccountId: expect.any(Function), + validateInput: expect.any(Function), + })); + expect(beeperChannelPlugin.setupWizard).toEqual(expect.objectContaining({ + channel: "beeper", + configure: expect.any(Function), + configureInteractive: expect.any(Function), + getStatus: expect.any(Function), + })); + expect(beeperChannelPlugin.gateway).toEqual(expect.objectContaining({ + startAccount: expect.any(Function), + stopAccount: expect.any(Function), + })); + expect(beeperChannelPlugin.status).toBe(beeperStatusAdapter); + + const cfg = beeperSetupAdapter.applyAccountConfig({ + accountId: "@alice:beeper.com", + cfg: {}, + input: {}, + }); + expect(cfg).not.toHaveProperty("then"); + expect(getBeeperAccountSettings(cfg, "@alice:beeper.com")).toMatchObject({ enabled: true }); + }); + + it("starts the Beeper bridge from OpenClaw gateway lifecycle and stops on abort", async () => { + const stop = vi.fn(async () => undefined); + appserviceMocks.startOpenClawBeeperBridge.mockResolvedValueOnce({ stop }); + const abort = new AbortController(); + const statuses: unknown[] = []; + const channelRuntime = { + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + session: { recordInboundSession: vi.fn() }, + inbound: { buildContext: vi.fn(), dispatchReply: vi.fn() }, + }; + const cfg = applyBeeperAccountSettings({}, "@alice:example", { + asToken: "as", + dataDir: "/tmp/openclaw-beeper", + enabled: true, + hsToken: "hs", + bridge: { + homeserver: "https://matrix.example", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + }); + + const task = startBeeperGatewayAccount({ + abortSignal: abort.signal, + accountId: "@alice:example", + cfg, + channelRuntime, + setStatus: (next) => statuses.push(next), + } as never); + await vi.waitFor(() => expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledOnce()); + expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledWith(expect.objectContaining({ + config: expect.objectContaining({ + dataDir: "/tmp/openclaw-beeper", + }), + dataDir: "/tmp/openclaw-beeper", + runtime: expect.objectContaining({ + channel: channelRuntime, + config: expect.objectContaining({ current: expect.any(Function) }), + }), + })); + const runtime = appserviceMocks.startOpenClawBeeperBridge.mock.calls[0]?.[0]?.runtime as { config?: { current?: () => unknown } }; + expect(runtime.config?.current?.()).toBe(cfg); + expect(statuses).toContainEqual(expect.objectContaining({ running: true })); + abort.abort(); + await task; + expect(stop).toHaveBeenCalledOnce(); + expect(statuses).toContainEqual(expect.objectContaining({ running: false })); + }); + + it("shares one running Beeper bridge startup per account", async () => { + const stop = vi.fn(async () => undefined); + appserviceMocks.startOpenClawBeeperBridge.mockResolvedValueOnce({ stop }); + const abort = new AbortController(); + const cfg = applyBeeperAccountSettings({}, "@alice:example", { + asToken: "as", + dataDir: "/tmp/openclaw-beeper", + enabled: true, + hsToken: "hs", + bridge: { + homeserver: "https://matrix.example", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + }); + const ctx = { + abortSignal: abort.signal, + accountId: "@alice:example", + cfg, + } as never; + + const first = startBeeperGatewayAccount(ctx); + const second = startBeeperGatewayAccount(ctx); + await vi.waitFor(() => expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledOnce()); + abort.abort(); + await Promise.all([first, second]); + + expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledOnce(); + expect(stop).toHaveBeenCalledOnce(); + }); + + it("rejects gateway startup until Beeper setup has complete credentials", async () => { + await expect(startBeeperGatewayAccount({ + abortSignal: new AbortController().signal, + accountId: "@alice:example", + cfg: applyBeeperAccountSettings({}, "@alice:example", { + enabled: true, + }), + })).rejects.toThrow("not fully configured"); + }); + + it("exposes the lightweight OpenClaw setup-entry contract", () => { + expect(setupEntry).toMatchObject({ + kind: "bundled-channel-setup-entry", + loadSetupPlugin: expect.any(Function), + }); + expect(setupEntry.loadSetupPlugin()).toMatchObject({ id: "beeper" }); + }); + + it("applies dashboard setup input into non-login channels.beeper settings", async () => { + const cfg = await beeperSetupAdapter.applyAccountConfig({ + accountId: "@alice:beeper.com", + cfg: {}, + input: { + serverEnv: "staging", + }, + }); + expect(getBeeperAccountSettings(cfg, "@alice:beeper.com")).toEqual({ + enabled: true, + serverEnv: "staging", + }); + expect(isBeeperChannelConfigured(cfg, "@alice:beeper.com")).toBe(false); + expect(cfg.plugins?.entries?.beeper).toBeUndefined(); + }); + + it("keeps async Beeper login out of the synchronous OpenClaw setup adapter", () => { + expect(() => beeperSetupAdapter.applyAccountConfig({ + accountId: "@alice:beeper.com", + cfg: {}, + input: { + email: "alice@example.com", + }, + })).toThrow("Beeper login runs through"); + + expect(() => beeperSetupAdapter.applyAccountConfig({ + accountId: "@alice:beeper.com", + cfg: {}, + input: { + password: "secret", + username: "alice", + }, + })).toThrow("Beeper login runs through"); + }); + + it("runs Beeper login and appservice registration from dashboard setup wizard input", async () => { + const progress = { + stop: () => {}, + update: () => {}, + }; + const promptValues: Record = { + "Beeper email": "alice@example.com", + "Beeper sign in code": "123456", + }; + const result = await beeperSetupWizard.configureInteractive({ + cfg: {}, + prompter: { + confirm: async ({ message }) => message === "Post bridge state to Beeper" ? false : true, + multiselect: async () => ["dashboard", "tui"], + progress: () => progress, + select: async ({ message }) => { + if (message === "Beeper server environment") return "prod"; + if (message === "Beeper login method") return "email"; + if (message === "Beeper contact visibility") return "agents"; + if (message === "Approval behavior") return "native"; + throw new Error(`unexpected select prompt ${message}`); + }, + text: async ({ message, validate }) => { + const value = promptValues[message]; + if (value === undefined) throw new Error(`unexpected text prompt ${message}`); + const error = validate?.(value); + if (error) throw new Error(error); + return value; + }, + }, + runtime: { + setupBridge: async (options) => { + expect(options.email).toBe("alice@example.com"); + expect(options.env).toBe("prod"); + expect(options).not.toHaveProperty("bridgeManagerToken"); + expect(options).not.toHaveProperty("homeserverDomain"); + expect(await options.getLoginCode?.()).toBe("123456"); + return { + account: { + accessToken: "at", + deviceId: "DEV", + homeserver: "https://matrix.example", + userId: "@alice:example", + }, + config: { + appserviceId: "sh-openclaw-dev", + asToken: "as", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + init: { + homeserver: "https://matrix.example", + registration: { + asToken: "as", + id: "sh-openclaw-dev", + hsToken: "hs", + url: "http://127.0.0.1:29391", + }, + } as never, + }; + }, + }, + }); + const cfg = result.cfg; + expect(result.accountId).toBe("@alice:example"); + expect(getBeeperAccountSettings(cfg, "@alice:example")).toMatchObject({ + enabled: true, + asToken: "as", + bridge: { + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.example", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + hsToken: "hs", + }); + }); + + it("infers generated bridge settings from username/password setup input", async () => { + const { applyBeeperSetupConfig } = await import("./setup"); + const result = await applyBeeperSetupConfig({ + cfg: {}, + input: { + password: "secret", + serverEnv: "dev", + username: "alice", + }, + runtime: { + setupBridge: async (options) => { + expect(options.email).toBeUndefined(); + expect(options.env).toBe("dev"); + expect(options.password).toBe("secret"); + expect(options.username).toBe("alice"); + return { + account: { + accessToken: "at", + deviceId: "DEV", + homeserver: "https://matrix.example", + userId: "@alice:example", + }, + config: { + appserviceId: "sh-openclaw-dev", + asToken: "as", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + init: { + homeserver: "https://matrix.example", + registration: { + asToken: "as", + id: "sh-openclaw-dev", + hsToken: "hs", + url: "http://127.0.0.1:29391", + }, + } as never, + }; + }, + }, + }); + expect(result.accountId).toBe("@alice:example"); + expect(getBeeperAccountSettings(result.cfg, "@alice:example")).toMatchObject({ + asToken: "as", + bridge: { + appserviceId: "sh-openclaw-dev", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.example", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + hsToken: "hs", + serverEnv: "dev", + }); + }); + + it("does not report configured until login, appservice, and gateway details are present", async () => { + expect(isBeeperChannelConfigured(applyBeeperAccountSettings({}, "@alice:example", { + enabled: true, + }), "@alice:example")).toBe(false); + const cfg = applyBeeperAccountSettings({}, "@alice:example", { + asToken: "as", + enabled: true, + hsToken: "hs", + bridge: { + homeserver: "https://matrix.example", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + }); + expect(isBeeperChannelConfigured(cfg, "@alice:example")).toBe(true); + }); + + it("applies setup input through the channel setup adapter implementation", async () => { + const { applyBeeperSetupConfig } = await import("./setup"); + const result = await applyBeeperSetupConfig({ + cfg: {}, + input: { + email: "alice@example.com", + getLoginCode: () => "123456", + serverEnv: "dev", + }, + runtime: { + setupBridge: async (options) => { + expect(options.email).toBe("alice@example.com"); + expect(options.env).toBe("dev"); + expect(options).not.toHaveProperty("bridgeManagerToken"); + expect(options).not.toHaveProperty("homeserverDomain"); + expect(await options.getLoginCode?.()).toBe("123456"); + return { + account: { + accessToken: "at", + deviceId: "DEV", + homeserver: "https://matrix.example", + userId: "@alice:example", + }, + config: { + appserviceId: "sh-openclaw-dev", + asToken: "as", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + init: { + homeserver: "https://matrix.example", + registration: { + asToken: "as", + id: "sh-openclaw-dev", + hsToken: "hs", + url: "http://127.0.0.1:29391", + }, + } as never, + }; + }, + }, + }); + expect(result.accountId).toBe("@alice:example"); + expect(getBeeperAccountSettings(result.cfg, "@alice:example")).toMatchObject({ + enabled: true, + asToken: "as", + bridge: { + appserviceId: "sh-openclaw-dev", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.example", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + hsToken: "hs", + }); + }); + + it("defaults new account setup to owned chats only", async () => { + expect(defaultBeeperAccountSettings()).toMatchObject({ + enabled: true, + }); + const configured = await beeperSetupWizard.configure({ accountId: "@alice:beeper.com", cfg: {} }); + expect(configured.accountId).toBe("@alice:beeper.com"); + expect(getBeeperAccountSettings(configured.cfg, "@alice:beeper.com")).toMatchObject({ + enabled: true, + }); + }); + + it("reports setup status and validates dashboard input", async () => { + expect(validateBeeperSetupInput({ email: "not-email" })).toContain("valid email"); + expect(validateBeeperSetupInput({ username: "alice" })).toContain("requires both"); + expect(validateBeeperSetupInput({ email: "alice@example.com", username: "alice", password: "secret" })).toContain("only one"); + const cfg = applyBeeperAccountSettings({}, "@alice:beeper.com", { + enabled: true, + }); + await expect(beeperSetupWizard.getStatus({ cfg })).resolves.toMatchObject({ + channel: "beeper", + configured: false, + quickstartScore: 20, + statusLines: expect.arrayContaining([ + "Account: @alice:beeper.com", + "Server environment: prod", + ]), + }); + }); + + it("reports read-only Beeper login identity after setup", async () => { + const cfg = applyBeeperAccountSettings({}, "@alice:example", { + asToken: "as", + bridge: { + homeserver: "https://matrix.example", + homeserverDomain: "matrix.example", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + enabled: true, + hsToken: "hs", + serverEnv: "staging", + }); + + await expect(beeperSetupWizard.getStatus({ cfg })).resolves.toMatchObject({ + configured: true, + statusLines: expect.arrayContaining([ + "Server environment: staging (change requires logout and login)", + "Beeper user: @alice:example", + "Homeserver: matrix.example", + ]), + }); + }); + + it("reports lightweight channel status without starting bridge runtime", () => { + const account = beeperChannelConfig.resolveAccount(applyBeeperAccountSettings({}, "@alice:beeper.com", { + enabled: true, + })); + const snapshot = beeperStatusAdapter.buildAccountSnapshot({ account }); + + expect(snapshot).toMatchObject({ + accountId: "@alice:beeper.com", + configured: false, + enabled: true, + running: false, + }); + expect(beeperStatusAdapter.buildChannelSummary({ snapshot })).toMatchObject({ + configured: false, + enabled: true, + running: false, + }); + expect(beeperStatusAdapter.resolveAccountState({ configured: false, enabled: true })).toBe("not configured"); + expect(beeperStatusAdapter.collectStatusIssues([snapshot])).toEqual([ + expect.objectContaining({ + message: expect.stringContaining("not connected"), + severity: "warning", + }), + ]); + }); + + it("creates bridge runtime config from persisted channels.beeper settings", () => { + const cfg = createConfigFromOpenClawSetup({ + channels: { + beeper: { + accounts: { + "@alice:example": { + hsToken: "hs", + dataDir: "/tmp/beeper", + bridge: { + homeserver: "https://matrix.example", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + }, + }, + }, + }, + }, {}, "@alice:example"); + expect(cfg).toMatchObject({ + dataDir: "/tmp/beeper", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }); + }); + + it("routes OpenClaw message actions through the active Beeper runtime", async () => { + const client = { + appservice: { sendMessage: vi.fn(async () => ({ eventId: "$as" })) }, + beeper: { + aiRuns: createTestBeeperAIRuns(), + aiRunStreams: createTestBeeperAIRunStreams(), + streams: { + finalizeMessage: vi.fn(async () => ({ replacementEventId: "$replace", roomId: "!room", raw: {} })), + publishPart: vi.fn(async () => undefined), + startMessage: vi.fn(async () => ({ descriptor: { type: "com.beeper.llm" }, eventId: "$stream" })), + }, + }, + media: { upload: vi.fn(async () => ({ contentUri: "mxc://example/file", raw: {} })) }, + messages: { + edit: vi.fn(async () => ({ eventId: "$edit" })), + redact: vi.fn(async () => undefined), + send: vi.fn(async () => ({ eventId: "$send" })), + sendMedia: vi.fn(async () => ({ eventId: "$media" })), + }, + reactions: { + redact: vi.fn(async () => undefined), + send: vi.fn(async () => ({ eventId: "$reaction" })), + }, + typing: { set: vi.fn(async () => undefined) }, + }; + const queued: unknown[] = []; + const bridge = { + createBeeperTurnStream: vi.fn((options) => new BeeperTurnStream({ + ...options, + client: client as never, + })), + flushRemoteEvents: vi.fn(async () => undefined), + getPortalByMXID: vi.fn(() => ({ portalKey: { id: "conversation:one", receiver: "openclaw:plugin" } })), + queueRemoteEvent: vi.fn((_login: unknown, event: unknown) => queued.push(event)), + }; + const runtime = new BeeperChannelRuntime({ + bridge: bridge as never, + getAgents: () => [{ + avatarMxc: "mxc://avatar", + description: "Helpful coding agent", + agentId: "codex", + displayName: "Codex", + ghostUserId: "@codex:example", + }], + getBindingByRoom: () => ({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example", + id: "binding", + roomId: "!room", + sessionKey: "session_1", + updatedAt: 1, + }), + login: { id: "openclaw:plugin" }, + }); + const hostRuntime = {}; + setBeeperOpenClawPluginRuntime(hostRuntime); + setBeeperChannelRuntimeForHost(hostRuntime, runtime); + runtime.createStreamPublisher({ + agentId: "codex", + roomId: "!room", + runId: "run_1", + sessionKey: "session_1", + }); + + const sentMessageId = "openclaw:message:test"; + + await beeperChannelPlugin.actions.handleAction({ + action: "send", + params: { message: "hello from tool" }, + sessionKey: "session_1", + }); + expect(client.beeper.aiRunStreams.start).toHaveBeenCalledWith(expect.objectContaining({ + initialEvents: [expect.objectContaining({ + delta: "hello from tool", + type: "TEXT_MESSAGE_CONTENT", + })], + runId: "run_1", + })); + expect(client.beeper.aiRunStreams.appendEvent).not.toHaveBeenCalled(); + + await beeperChannelPlugin.actions.handleAction({ + action: "react", + params: { eventId: sentMessageId, emoji: "+1", roomId: "!room" }, + }); + expect(client.reactions.send).not.toHaveBeenCalled(); + + await beeperChannelPlugin.heartbeat.sendTyping({ to: "!room" }); + expect(client.typing.set).not.toHaveBeenCalled(); + await beeperChannelPlugin.actions.handleAction({ + action: "edit", + params: { eventId: sentMessageId, message: "edited", roomId: "!room" }, + }); + await beeperChannelPlugin.actions.handleAction({ + action: "delete", + params: { eventId: sentMessageId, roomId: "!room" }, + }); + await beeperChannelPlugin.actions.handleAction({ + action: "read", + params: { eventId: sentMessageId, roomId: "!room" }, + }); + await beeperChannelPlugin.actions.handleAction({ + action: "mark_unread", + params: { eventId: sentMessageId, roomId: "!room" }, + }); + await expect(beeperChannelPlugin.actions.handleAction({ + action: "channel-info", + params: { channelId: "!room" }, + })).resolves.toMatchObject({ + details: { + action: "channel-info", + ok: true, + }, + }); + await beeperChannelPlugin.actions.handleAction({ + action: "channel-edit", + params: { avatarMxc: "mxc://example/avatar2", channelId: "!room", name: "Agent room", topic: "Planning" }, + }); + expect(queued.map((event) => (event as { getType: () => string }).getType())).toEqual([ + "reaction", + "typing", + "edit", + "message_remove", + "read_receipt", + "mark_unread", + "chat_info_change", + "chat_info_change", + "chat_info_change", + ]); + + await expect(beeperChannelPlugin.directory.listPeersLive({ + cfg: {} as OpenClawSetupConfig, + })).resolves.toEqual([{ + avatarUrl: "mxc://avatar", + description: "Helpful coding agent", + handle: "codex", + id: "codex", + kind: "user", + name: "Codex", + raw: { + avatarMxc: "mxc://avatar", + description: "Helpful coding agent", + agentId: "codex", + displayName: "Codex", + ghostUserId: "@codex:example", + }, + }]); + }); + + it("lists saved bridge registry agents as directory contacts without live runtime", async () => { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "pickle-openclaw-directory-")); + await fs.writeFile(path.join(dataDir, "registry.json"), JSON.stringify({ + agents: [{ + agentId: "codex", + avatarMxc: "mxc://avatar", + description: "Helpful coding agent", + displayName: "Codex", + ghostUserId: "@codex:example", + }], + bindings: [], + dedupe: {}, + schemaVersion: 1, + })); + setBeeperOpenClawPluginRuntime(undefined); + + await expect(beeperChannelPlugin.directory.listPeers({ + cfg: { channels: { beeper: { accounts: { "@alice:beeper.com": { dataDir } } } } } as OpenClawSetupConfig, + query: "helpful", + })).resolves.toEqual([{ + avatarUrl: "mxc://avatar", + description: "Helpful coding agent", + handle: "codex", + id: "codex", + kind: "user", + name: "Codex", + raw: { + agentId: "codex", + avatarMxc: "mxc://avatar", + description: "Helpful coding agent", + displayName: "Codex", + ghostUserId: "@codex:example", + }, + }]); + }); + + it("reads plugin-entry channel account config", () => { + expect(getBeeperChannelSettings({ + channels: { + beeper: { + accounts: { + "@alice:beeper.com": { + serverEnv: "staging", + }, + }, + }, + }, + plugins: { + entries: { + beeper: { + config: { + enabled: true, + }, + }, + }, + }, + })).toEqual({ + accounts: { + "@alice:beeper.com": { + serverEnv: "staging", + }, + }, + }); + + expect(getBeeperAccountSettings({ + channels: { + beeper: { + accounts: { + "@alice:beeper.com": { + serverEnv: "staging", + }, + }, + }, + }, + }, "@alice:beeper.com")).toEqual({ + serverEnv: "staging", + }); + + expect(createConfigFromOpenClawSetup({ plugins: { entries: { beeper: { config: {} } } } })).toMatchObject({ + appserviceId: "sh-openclaw", + }); + }); + + it("uses official channels.beeper accounts for multiple Beeper accounts", () => { + const cfg = applyBeeperAccountSettings({ + channels: { + beeper: { + defaultAccount: "@work:beeper.com", + }, + }, + } as OpenClawSetupConfig, "work:beeper.com", { + dataDir: "/work", + enabled: true, + name: "Work Beeper", + serverEnv: "staging", + }); + + expect(listBeeperAccountIds(cfg)).toEqual(["@work:beeper.com"]); + expect(resolveDefaultBeeperAccountId(cfg)).toBe("@work:beeper.com"); + expect(getBeeperAccountSettings(cfg, "@work:beeper.com")).toMatchObject({ + dataDir: "/work", + name: "Work Beeper", + serverEnv: "staging", + }); + expect(beeperChannelConfig.resolveAccount(cfg, "work:beeper.com")).toMatchObject({ + accountId: "@work:beeper.com", + settings: { name: "Work Beeper" }, + }); + }); + + it("allows non-exclusive agent account assignment with per-agent defaults", () => { + const cfg = { + channels: { + beeper: { + defaultAccount: "@personal:beeper.com", + accounts: { + "@personal:beeper.com": { enabled: true }, + "@work:beeper.com": { enabled: true }, + "@alerts:beeper.com": { enabled: true }, + }, + agents: { + codex: { + accountIds: ["@work:beeper.com", "alerts:beeper.com"], + defaultAccount: "@alerts:beeper.com", + }, + helper: { + accountIds: ["work:beeper.com"], + }, + }, + }, + }, + } as OpenClawSetupConfig; + + expect(resolveBeeperAgentAccountId(cfg, "codex")).toBe("@alerts:beeper.com"); + expect(resolveBeeperAgentAccountId(cfg, "codex", "work:beeper.com")).toBe("@work:beeper.com"); + expect(resolveBeeperAgentAccountId(cfg, "helper")).toBe("@work:beeper.com"); + expect(resolveBeeperAgentAccountId(cfg, "unassigned")).toBe("@personal:beeper.com"); + expect(() => resolveBeeperAgentAccountId(cfg, "helper", "alerts:beeper.com")).toThrow(/not assigned/); + }); + + it("derives account ids from Beeper Matrix user ids", () => { + expect(beeperAccountIdFromMatrixUserId("@alice:beeper.com")).toBe("@alice:beeper.com"); + expect(beeperAccountIdFromMatrixUserId("Alice.Work:beeper.com")).toBe("@Alice.Work:beeper.com"); + expect(beeperAccountIdFromMatrixUserId("batuhan")).toBeUndefined(); + }); +}); + +function createTestBeeperAIRuns() { + const snapshot = (runId: string, events: Record[] = []) => ({ + body: "...", + events, + finalAIMessage: {}, + initialAIMessage: {}, + metadata: {}, + messageId: runId, + runId, + threadId: runId, + }); + return { + appendEvent: vi.fn(async ({ event, runId }: { event: Record; runId: string }) => + snapshot(runId, [event])), + begin: vi.fn(async ({ runId, threadId }: { runId: string; threadId?: string }) => + snapshot(runId, [ + { runId, threadId: threadId ?? runId, type: "RUN_STARTED" }, + { messageId: runId, role: "assistant", type: "TEXT_MESSAGE_START" }, + ])), + delete: vi.fn(async () => undefined), + error: vi.fn(async ({ message, runId }: { message?: string; runId: string }) => + snapshot(runId, [{ message, runId, type: "RUN_ERROR" }])), + finish: vi.fn(async ({ finishReason, runId }: { finishReason?: string; runId: string }) => + snapshot(runId, [{ finishReason: finishReason ?? "stop", runId, type: "RUN_FINISHED" }])), + }; +} + +function createTestBeeperAIRunStreams() { + const result = (runId: string, events: Record[] = []) => ({ + body: "...", + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream", + events, + finalAIMessage: {}, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: {}, + raw: {}, + replacementEventId: "$replace", + roomId: "!room", + runId, + threadId: runId, + }); + return { + appendEvent: vi.fn(async ({ event, runId }: { event: Record; runId: string }) => + result(runId, [event])), + error: vi.fn(async ({ message, runId }: { message?: string; runId: string }) => + result(runId, [{ message, runId, type: "RUN_ERROR" }])), + finish: vi.fn(async ({ finishReason, runId }: { finishReason?: string; runId: string }) => + result(runId, [{ finishReason: finishReason ?? "stop", runId, type: "RUN_FINISHED" }])), + start: vi.fn(async ({ runId }: { runId: string }) => + result(runId, [ + { runId, threadId: runId, type: "RUN_STARTED" }, + { messageId: `msg-${runId}`, role: "assistant", type: "TEXT_MESSAGE_START" }, + ])), + }; +} diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts new file mode 100644 index 0000000..9d2bfa2 --- /dev/null +++ b/packages/openclaw/src/setup.ts @@ -0,0 +1,1529 @@ +import { createChannelPluginBase, createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/channel-core"; +import type { ChatType } from "openclaw/plugin-sdk/core"; +import type { ChannelAccountSnapshot, ChannelCapabilities, ChannelGatewayContext, ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract"; +import type { SecretInput } from "openclaw/plugin-sdk/secret-input-runtime"; +import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime"; +import type { BridgeLogger } from "@beeper/pickle-bridge/types"; +import { beeperAccountIdFromMatrixUserId, normalizeBeeperAccountId, requireBeeperAccountId } from "./account-id"; +import { createConfigFromOpenClawSetup, createRuntimeConfigFromOpenClawSetup, defaultDataDir } from "./config"; +import beeperChannelConfigSchema from "./beeper-channel-config.schema.json"; +import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from "./beeper-setup"; +import { createBeeperApprovalNotice } from "./approval"; +import { requireBeeperChannelRuntimeForHost, setBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; +import type { OpenClawHostRuntime } from "./openclaw-runtime"; +import { OpenClawBridgeRegistry, defaultRegistryPath } from "./registry"; +import type { BeeperServerEnv } from "./types"; + +export { beeperAccountIdFromMatrixUserId } from "./account-id"; + +export type OpenClawSetupConfig = OpenClawConfig; + +export interface BeeperChannelSettings { + accounts?: Record; + defaultAccount?: string; + agents?: Record; +} + +export interface BeeperAccountSettings { + asToken?: SecretInput; + bridge?: BeeperGeneratedBridgeSettings; + dataDir?: string; + enabled?: boolean; + hsToken?: SecretInput; + name?: string; + serverEnv?: BeeperServerEnv; +} + +export interface BeeperAgentAccountSettings { + accountIds?: string[]; + defaultAccount?: string; +} + +export interface BeeperGeneratedBridgeSettings { + appserviceId?: string; + bridgeId?: string; + homeserver?: string; + homeserverDomain?: string; + matrixDeviceId?: string; + matrixUserId?: string; +} + +export interface BeeperSetupInput { + dataDir?: string; + email?: string; + getLoginCode?: () => Promise | string; + password?: string; + serverEnv?: string; + username?: string; +} + +export interface BeeperSetupRuntime { + setupBridge?: (options: SetupOpenClawBeeperBridgeOptions) => Promise>>; +} + +type StartedBeeperBridge = { + stop?: () => Promise | void; +}; + +type StartingBeeperBridge = { + promise: Promise; +}; + +type BeeperGatewayContext = { + abortSignal: AbortSignal; + accountId: string; + cfg: OpenClawSetupConfig; + channelRuntime?: unknown; + hostRuntime?: unknown; + log?: { + info?: (message: string) => void; + warn?: (message: string) => void; + error?: (message: string) => void; + }; + runtime?: unknown; + setStatus?: (next: ChannelAccountSnapshot) => void; +}; + +type BeeperWizardPrompter = { + confirm: (params: { message: string; initialValue?: boolean }) => Promise; + multiselect: (params: { + message: string; + options: Array<{ value: T; label: string; hint?: string }>; + initialValues?: T[]; + searchable?: boolean; + }) => Promise; + progress?: (label: string) => { update: (message: string) => void; stop: (message?: string) => void }; + select: (params: { + message: string; + options: Array<{ value: T; label: string; hint?: string }>; + initialValue?: T; + searchable?: boolean; + }) => Promise; + text: (params: { + message: string; + initialValue?: string; + placeholder?: string; + sensitive?: boolean; + validate?: (value: string) => string | undefined; + }) => Promise; +}; + +export const BEEPER_CHANNEL_ID = "beeper"; + +let openClawPluginRuntime: object | undefined; + +export function setBeeperOpenClawPluginRuntime(runtime: unknown): void { + openClawPluginRuntime = typeof runtime === "object" && runtime !== null ? runtime : undefined; +} + +function requireBeeperChannelRuntime() { + return requireBeeperChannelRuntimeForHost(openClawPluginRuntime); +} + +export const BeeperChannelConfigSchema = beeperChannelConfigSchema; + +export const BeeperChannelUiHints = { + "accounts.*.asToken": { sensitive: true, tags: ["hidden"] as string[] }, + "accounts.*.hsToken": { sensitive: true, tags: ["hidden"] as string[] }, + "accounts.*.serverEnv": { + help: "Choose before Beeper login. To change it after connecting, log out and log back in.", + }, +} as const; + +export const beeperMessageAdapter = { + id: BEEPER_CHANNEL_ID, + durableFinal: { + capabilities: { + media: true, + messageSendingHooks: true, + replyTo: true, + text: true, + thread: true, + }, + }, + live: { + capabilities: { + nativeStreaming: true, + previewFinalization: true, + progressUpdates: true, + quietFinalization: true, + }, + finalizer: { + capabilities: { + finalEdit: true, + normalFallback: false, + previewReceipt: true, + retainOnAmbiguousFailure: true, + }, + }, + }, + receive: { + defaultAckPolicy: "after_agent_dispatch", + supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"], + }, + send: { + text: async (ctx: { + cfg: OpenClawSetupConfig; + to: string; + text: string; + replyToId?: string | null; + threadId?: string | number | null; + }) => beeperMessageSendResult(await beeperOutboundAdapter.sendText(ctx)), + media: async (ctx: { + cfg: OpenClawSetupConfig; + to: string; + text?: string; + mediaUrl?: string; + mediaReadFile?: (filePath: string) => Promise; + replyToId?: string | null; + threadId?: string | number | null; + }) => beeperMessageSendResult(await beeperOutboundAdapter.sendMedia(ctx)), + payload: async (ctx: { + cfg: OpenClawSetupConfig; + to: string; + text?: string; + mediaUrl?: string; + mediaReadFile?: (filePath: string) => Promise; + payload?: unknown; + replyToId?: string | null; + threadId?: string | number | null; + }) => beeperMessageSendResult(await beeperOutboundAdapter.sendPayload(ctx)), + }, +} as const; + +export const beeperOutboundAdapter = { + deliveryMode: "direct", + sendText: async (ctx: { + to: string; + text: string; + replyToId?: string | null; + threadId?: string | number | null; + }) => { + const runtime = requireBeeperChannelRuntime(); + const sent = await runtime.sendText({ + roomId: resolveBeeperRoomTarget(ctx.to), + text: ctx.text, + ...(ctx.replyToId ? { replyToId: ctx.replyToId } : {}), + ...(ctx.threadId != null ? { threadRoot: ctx.threadId } : {}), + }); + return beeperOutboundResult(sent); + }, + sendMedia: async (ctx: { + to: string; + text?: string; + mediaUrl?: string; + mediaReadFile?: (filePath: string) => Promise; + threadId?: string | number | null; + }) => { + const runtime = requireBeeperChannelRuntime(); + const mediaUrl = ctx.mediaUrl?.trim(); + if (!mediaUrl) { + return await beeperOutboundAdapter.sendText({ + to: ctx.to, + text: ctx.text ?? "", + ...(ctx.threadId != null ? { threadId: ctx.threadId } : {}), + }); + } + const bytes = ctx.mediaReadFile ? await ctx.mediaReadFile(mediaUrl) : undefined; + const filename = mediaUrl.split("/").pop(); + const mediaOptions = { + roomId: resolveBeeperRoomTarget(ctx.to), + ...(bytes !== undefined ? { bytes } : {}), + ...(ctx.text !== undefined ? { caption: ctx.text } : {}), + ...(filename ? { filename } : {}), + ...(bytes === undefined ? { path: mediaUrl } : {}), + ...(ctx.threadId != null ? { threadRoot: String(ctx.threadId) } : {}), + }; + const sent = await runtime.sendMedia(mediaOptions); + return beeperOutboundResult(sent); + }, + sendPayload: async (ctx: { + to: string; + text?: string; + mediaUrl?: string; + mediaReadFile?: (filePath: string) => Promise; + payload?: unknown; + replyToId?: string | null; + threadId?: string | number | null; + }) => { + const mediaUrl = ctx.mediaUrl ?? firstPayloadMediaUrl(ctx.payload); + const text = ctx.text ?? firstPayloadText(ctx.payload) ?? ""; + if (mediaUrl) { + return await beeperOutboundAdapter.sendMedia({ + mediaUrl, + text, + to: ctx.to, + ...(ctx.mediaReadFile !== undefined ? { mediaReadFile: ctx.mediaReadFile } : {}), + ...(ctx.threadId != null ? { threadId: ctx.threadId } : {}), + }); + } + return await beeperOutboundAdapter.sendText({ + text, + to: ctx.to, + ...(ctx.replyToId ? { replyToId: ctx.replyToId } : {}), + ...(ctx.threadId != null ? { threadId: ctx.threadId } : {}), + }); + }, +} as const; + +export const beeperMessagingAdapter = { + defaultMarkdownTableMode: "bullets", + targetPrefixes: ["beeper", "agent", "openclaw"], + normalizeTarget: normalizeBeeperMessagingTarget, + resolveInboundConversation: ({ to, conversationId, threadId }: { + to?: string; + conversationId?: string; + threadId?: string | number; + isGroup: boolean; + }) => { + const id = normalizeBeeperConversationId(conversationId ?? to); + if (!id) return null; + return stripUndefined({ + conversationId: id, + ...(threadId !== undefined ? { parentConversationId: id } : {}), + }); + }, + resolveDeliveryTarget: ({ conversationId }: { conversationId: string; parentConversationId?: string }) => ({ + to: normalizeBeeperConversationId(conversationId) ?? conversationId, + }), + resolveSessionConversation: ({ kind, rawId }: { kind: "group" | "channel"; rawId: string }) => + kind === "channel" + ? { + baseConversationId: normalizeBeeperConversationId(rawId) ?? rawId, + id: normalizeBeeperConversationId(rawId) ?? rawId, + parentConversationCandidates: [normalizeBeeperConversationId(rawId) ?? rawId], + } + : null, + resolveSessionTarget: ({ id }: { kind: "group" | "channel"; id: string }) => `beeper:${id}`, + inferTargetChatType: (): ChatType => "direct", + formatTargetDisplay: ({ target, display }: { target: string; display?: string }) => + display?.trim() || formatBeeperTargetDisplay(target), + resolveOutboundSessionRoute: (params: { + cfg: OpenClawSetupConfig; + agentId: string; + accountId?: string | null; + target: string; + resolvedTarget?: { to?: string }; + }) => { + const target = normalizeBeeperMessagingTarget(params.resolvedTarget?.to ?? params.target); + if (!target) return null; + const accountId = resolveBeeperAgentAccountId(params.cfg, params.agentId, params.accountId); + const sessionKey = [ + "agent", + params.agentId, + BEEPER_CHANNEL_ID, + accountId, + "direct", + target, + ].join(":"); + return { + baseSessionKey: sessionKey, + chatType: "direct" as const, + from: `beeper:${target}`, + peer: { kind: "direct" as const, id: target }, + sessionKey, + to: `beeper:${target}`, + }; + }, + targetResolver: { + hint: "", + looksLikeId: (value: string) => Boolean(normalizeBeeperMessagingTarget(value)), + resolveTarget: async ({ input, normalized }: { input: string; normalized: string }) => { + const target = normalizeBeeperMessagingTarget(normalized) ?? normalizeBeeperMessagingTarget(input); + return target + ? { + display: formatBeeperTargetDisplay(target), + kind: "user" as const, + source: "normalized" as const, + to: target, + } + : null; + }, + }, +} as const; + +export const beeperConversationBindings = { + supportsCurrentConversationBinding: true, + defaultTopLevelPlacement: "current", + resolveConversationRef: ({ conversationId, parentConversationId }: { + accountId?: string | null; + conversationId: string; + parentConversationId?: string; + threadId?: string | number | null; + }) => stripUndefined({ + conversationId: normalizeBeeperConversationId(conversationId) ?? conversationId, + ...(parentConversationId ? { parentConversationId } : {}), + }), + buildBoundReplyPayload: ({ operation, conversation }: { + operation: "acp-spawn"; + placement: "current" | "child"; + conversation: { channel: string; accountId?: string | null; conversationId: string; parentConversationId?: string }; + }) => operation === "acp-spawn" + ? { + channelData: { + beeper: { + conversationId: conversation.conversationId, + kind: "agent_dm", + }, + }, + } + : null, +} as const; + +export const beeperDirectoryAdapter = { + listPeers: async ({ cfg, query, limit }: { + cfg: OpenClawSetupConfig; + query?: string | null; + limit?: number | null; + }) => listLiveOrConfiguredAgentDirectoryEntries(cfg, query, limit), + listPeersLive: async ({ cfg, query, limit }: { + cfg: OpenClawSetupConfig; + query?: string | null; + limit?: number | null; + }) => listLiveOrConfiguredAgentDirectoryEntries(cfg, query, limit), + listGroups: async () => [], +} as const; + +export const beeperResolverAdapter = { + resolveTargets: async ({ cfg, inputs, kind }: { + cfg: OpenClawSetupConfig; + accountId?: string | null; + inputs: string[]; + kind: "user" | "group"; + }) => { + if (kind === "group") { + return inputs.map((input) => ({ + input, + note: "Beeper OpenClaw v1 supports agent DMs only.", + resolved: false as const, + })); + } + const peers = await beeperDirectoryAdapter.listPeers({ cfg }); + return inputs.map((input) => { + const target = normalizeBeeperMessagingTarget(input); + if (!target) return { input, resolved: false as const }; + const directoryHit = peers.find((peer) => + peer.id.toLowerCase() === target.toLowerCase() || + peer.handle?.toLowerCase() === target.toLowerCase() || + peer.name?.toLowerCase() === target.toLowerCase() + ); + return { + id: directoryHit?.id ?? target, + input, + name: directoryHit?.name ?? formatBeeperTargetDisplay(target), + resolved: true as const, + }; + }); + }, +} as const; + +export const beeperHeartbeatAdapter = { + sendTyping: async ({ to }: { to: string }) => { + await requireBeeperChannelRuntime().typing({ roomId: resolveBeeperRoomTarget(to) }); + }, + clearTyping: async ({ to }: { to: string }) => { + await requireBeeperChannelRuntime().typing({ roomId: resolveBeeperRoomTarget(to), typing: false }); + }, +} as const; + +export const beeperApprovalCapability = { + initiatingSurface: { + exec: () => ({ kind: "enabled" }), + plugin: () => ({ kind: "enabled" }), + }, + render: { + exec: { + buildPendingPayload: ({ request, nowMs }: { request: { id?: string; approvalId?: string; command?: string; toolCallId?: string; toolName?: string; expiresAtMs?: number }; nowMs: number }) => { + const approvalId = request.approvalId ?? request.id ?? `approval_${nowMs}`; + const toolName = request.toolName ?? request.command ?? "OpenClaw tool"; + const body = `Approval requested: ${request.command ?? request.id ?? request.approvalId ?? "OpenClaw tool call"}`; + const notice = createBeeperApprovalNotice({ + approvalId, + body, + input: { + command: request.command, + createdAtMs: nowMs, + kind: "exec", + }, + messageId: approvalId, + toolCallId: request.toolCallId ?? approvalId, + toolName, + ...(request.expiresAtMs !== undefined ? { expiresAtMs: request.expiresAtMs } : {}), + }); + return { + body, + channelData: { + beeper: { + approvalId, + createdAt: nowMs, + kind: "exec", + notice, + }, + }, + content: { + body, + msgtype: "m.notice", + ...notice, + }, + }; + }, + }, + }, +} as const; + +const beeperMessageToolActions = [ + "send", + "edit", + "delete", + "react", + "read", + "mark_unread", + "channel-info", + "channel-edit", +] as const; + +type BeeperMessageToolAction = typeof beeperMessageToolActions[number]; +type BeeperActionContext = { + action: string; + params: Record; + mediaReadFile?: (filePath: string) => Promise; + sessionKey?: string | null; +}; + +function beeperToolTextResult(text: string, details: Record = {}) { + return { content: [{ type: "text" as const, text }], details }; +} + +const beeperActionHandlers: Record Promise>> = { + send: async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const text = readRequiredString(ctx.params, "message"); + const sent = await runtime.publishActiveText({ + ...(ctx.sessionKey !== undefined ? { sessionKey: ctx.sessionKey } : {}), + text, + }); + return beeperToolTextResult(`Published Beeper native stream text ${sent.eventId}`); + }, + edit: async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const roomId = readRequiredBeeperRoomId(ctx.params); + const eventId = readRequiredString(ctx.params, "eventId"); + const text = readRequiredString(ctx.params, "message"); + const sent = await runtime.edit({ eventId, roomId, text }); + return beeperToolTextResult(`Edited Beeper message ${sent.eventId}`); + }, + delete: async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const roomId = readRequiredBeeperRoomId(ctx.params); + const eventId = readRequiredString(ctx.params, "eventId"); + await runtime.redact({ eventId, roomId }); + return beeperToolTextResult(`Deleted Beeper message ${eventId}`); + }, + react: async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const roomId = readRequiredBeeperRoomId(ctx.params); + const eventId = readRequiredString(ctx.params, "eventId"); + const emoji = readRequiredString(ctx.params, "emoji"); + if (ctx.params.remove === true) { + await runtime.removeReaction({ emoji, eventId, roomId }); + return beeperToolTextResult(`Removed Beeper reaction ${emoji}`); + } + const sent = await runtime.react({ emoji, eventId, roomId }); + return beeperToolTextResult(`Sent Beeper reaction ${sent.eventId}`); + }, + read: async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const roomId = readRequiredBeeperRoomId(ctx.params); + const eventId = readRequiredString(ctx.params, "eventId"); + await runtime.readReceipt({ eventId, roomId }); + return beeperToolTextResult(`Marked Beeper message read ${eventId}`); + }, + mark_unread: async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const roomId = readRequiredBeeperRoomId(ctx.params); + const eventId = readRequiredString(ctx.params, "eventId"); + const unread = ctx.params.unread !== false; + await runtime.markUnread({ eventId, roomId, unread }); + return beeperToolTextResult(`${unread ? "Marked" : "Unmarked"} Beeper room unread`); + }, + "channel-info": async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const roomId = readRequiredBeeperRoomId(ctx.params, "channelId", "roomId"); + const info = runtime.getRoomInfo({ roomId }); + return beeperToolTextResult(`Beeper channel ${roomId}`, { + action: "channel-info", + channel: info, + ok: true, + }); + }, + "channel-edit": async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const roomId = readRequiredBeeperRoomId(ctx.params, "channelId", "roomId"); + const name = readOptionalString(ctx.params, "name", "displayName", "title"); + const topic = readOptionalString(ctx.params, "topic", "description"); + const avatarMxc = readOptionalString(ctx.params, "avatarMxc", "avatarUrl", "icon"); + if (!name && !topic && !avatarMxc) throw new Error("Beeper channel-edit requires name, topic, or avatarMxc."); + if (name) await runtime.setRoomName({ name, roomId }); + if (topic) await runtime.setRoomTopic({ roomId, topic }); + if (avatarMxc) { + if (!avatarMxc.startsWith("mxc://")) throw new Error("Beeper channel avatar must be an mxc:// URI."); + await runtime.setRoomAvatar({ avatarMxc, roomId }); + } + return beeperToolTextResult("Updated Beeper channel", { + action: "channel-edit", + channelId: roomId, + ok: true, + updates: stripUndefined({ avatarMxc, name, topic }), + }); + }, +}; + +export const beeperMessageActions = { + resolveExecutionMode: () => "gateway" as const, + describeMessageTool: () => ({ + actions: beeperMessageToolActions as unknown as readonly ChannelMessageActionName[], + capabilities: [], + }), + supportsAction: ({ action }: { action: string }) => + isBeeperMessageToolAction(action), + extractToolSend: () => null, + handleAction: async (ctx: BeeperActionContext) => { + const handler = isBeeperMessageToolAction(ctx.action) ? beeperActionHandlers[ctx.action] : undefined; + if (handler) return handler(ctx); + throw new Error(`Unsupported Beeper message action: ${ctx.action}`); + }, +} as const; + +export const beeperCommandAdapter = { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: true, + skipWhenConfigEmpty: false, +} as const; + +export const beeperAgentPromptAdapter = { + inboundFormattingHints: () => ({ + rules: [ + "Beeper OpenClaw rooms are direct chats between the owner and one OpenClaw agent ghost.", + "Matrix replies, edits, reactions, redactions, mentions, and attachments are forwarded as structured metadata when available.", + "Native Beeper streaming renders assistant text, tool calls, approvals, and terminal status incrementally.", + ], + text_markup: "Matrix-flavored plain text with optional formatted_body metadata", + }), + messageToolCapabilities: () => ["reactions"], + reactionGuidance: () => ({ channelLabel: "Beeper", level: "minimal" as const }), +} as const; + +export const beeperSetupAdapter = { + resolveAccountId: ({ accountId }: { accountId?: string | null } = {}) => normalizeBeeperAccountId(accountId) ?? "", + resolveBindingAccountId: ({ accountId, cfg }: { accountId?: string | null; agentId?: string | null; cfg?: OpenClawSetupConfig } = {}) => + normalizeBeeperAccountId(accountId) ?? (cfg ? resolveDefaultBeeperAccountId(cfg) : undefined) ?? "", + applyAccountName: ({ cfg }: { cfg: OpenClawSetupConfig }) => cfg, + validateInput: ({ input }: { input: BeeperSetupInput }) => validateBeeperSetupInput(input), + applyAccountConfig: ({ + accountId, + cfg, + input, + }: { + cfg: OpenClawSetupConfig; + accountId?: string | null; + input: BeeperSetupInput; + runtime?: BeeperSetupRuntime; + }): OpenClawSetupConfig => { + if (input.email || input.username || input.password) { + throw new Error("Beeper login runs through OpenClaw channel setup."); + } + return applyBeeperAccountSettings(cfg, requireBeeperAccountId(accountId), normalizeBeeperSetupInput(input)); + }, +}; + +export const beeperSetupWizard = { + channel: BEEPER_CHANNEL_ID, + async getStatus(ctx: { accountId?: string | null; cfg: OpenClawSetupConfig }) { + const accountId = resolveSetupStatusAccountId(ctx.cfg, ctx.accountId); + const settings = getBeeperAccountSettings(ctx.cfg, accountId); + const configured = isBeeperChannelConfigured(ctx.cfg, accountId); + const serverEnv = settings.serverEnv ?? "prod"; + const bridge = settings.bridge; + return { + channel: BEEPER_CHANNEL_ID, + configured, + statusLines: [ + `Connected: ${configured ? "yes" : "no"}`, + `Account: ${accountId || "not configured"}`, + `Server environment: ${serverEnv}${configured ? " (change requires logout and login)" : ""}`, + ...(configured && bridge?.matrixUserId ? [`Beeper user: ${bridge.matrixUserId}`] : []), + ...(configured && bridge?.homeserverDomain ? [`Homeserver: ${bridge.homeserverDomain}`] : []), + "Requires an existing Beeper account.", + "Pickle creates OpenClaw agent DMs on Beeper.", + "It does not give OpenClaw access to your Beeper chats. For that, ask your agent to use the Beeper CLI.", + ], + selectionHint: configured ? "Connected to Beeper" : "Connect Beeper", + quickstartScore: configured ? 100 : 20, + }; + }, + async configure(ctx: { accountId?: string | null; cfg: OpenClawSetupConfig }) { + const accountId = requireBeeperAccountId(ctx.accountId); + return { + accountId, + cfg: applyBeeperAccountSettings(ctx.cfg, accountId, defaultBeeperAccountSettings()), + }; + }, + async configureInteractive(ctx: { + accountId?: string | null; + cfg: OpenClawSetupConfig; + runtime?: unknown; + prompter: BeeperWizardPrompter; + }) { + const requestedAccountId = normalizeBeeperAccountId(ctx.accountId); + const currentAccountId = requestedAccountId ?? resolveDefaultBeeperAccountId(ctx.cfg); + const current = { + ...defaultBeeperAccountSettings(), + ...(currentAccountId ? getBeeperAccountSettings(ctx.cfg, currentAccountId) : {}), + }; + if (requestedAccountId && isBeeperChannelConfigured(ctx.cfg, requestedAccountId)) { + throw new Error(`Beeper account "${requestedAccountId}" is already connected. Add another account or log out before changing its Beeper server environment or login account.`); + } + const serverEnv = await ctx.prompter.select({ + message: "Beeper server environment", + initialValue: current.serverEnv ?? "prod", + options: [ + { value: "prod", label: "Production" }, + { value: "staging", label: "Staging" }, + { value: "dev", label: "Development" }, + { value: "local", label: "Local" }, + ], + }); + const loginMethod = await ctx.prompter.select<"email" | "password">({ + message: "Beeper login method", + initialValue: "email", + options: [ + { value: "email", label: "Email code" }, + { value: "password", label: "Username/password" }, + ], + }); + let email: string | undefined; + let username: string | undefined; + let password: string | undefined; + if (loginMethod === "email") { + email = await ctx.prompter.text({ + message: "Beeper email", + placeholder: "name@example.com", + validate: (value) => validateBeeperSetupInput({ email: value }) ?? undefined, + }); + } else if (loginMethod === "password") { + username = await ctx.prompter.text({ + message: "Beeper username", + validate: (value) => validateBeeperSetupInput({ username: value, password: "set" }) ?? undefined, + }); + password = await ctx.prompter.text({ + message: "Beeper password", + sensitive: true, + validate: (value) => validateBeeperSetupInput({ username: username ?? "set", password: value }) ?? undefined, + }); + } + const progress = ctx.prompter.progress?.("Setting up Beeper bridge"); + progress?.update(loginMethod === "email" ? "Sending Beeper sign in code" : "Signing in to Beeper"); + try { + const input: BeeperSetupInput = { + ...(email ? { email } : {}), + ...(email ? { + getLoginCode: async () => { + progress?.update("Waiting for Beeper sign in code"); + return ctx.prompter.text({ + message: "Beeper sign in code", + sensitive: true, + validate: (value) => (value.trim() ? undefined : "Beeper sign in code is required."), + }); + }, + } : {}), + ...(password ? { password } : {}), + ...(username ? { username } : {}), + }; + if (serverEnv !== undefined) input.serverEnv = serverEnv; + const setupParams: Parameters[0] = { + cfg: ctx.cfg, + input, + }; + if (requestedAccountId) setupParams.accountId = requestedAccountId; + const setupRuntime = beeperSetupRuntime(ctx.runtime); + if (setupRuntime) setupParams.runtime = setupRuntime; + const result = await applyBeeperSetupConfig(setupParams); + progress?.stop("Beeper bridge configured"); + return result; + } catch (error) { + progress?.stop("Beeper bridge setup failed"); + throw error; + } + }, + disable: (cfg: OpenClawSetupConfig) => disableAllBeeperAccounts(cfg), +}; + +export const beeperChannelConfig = { + listAccountIds: (cfg: OpenClawSetupConfig) => listBeeperAccountIds(cfg), + defaultAccountId: (cfg: OpenClawSetupConfig) => resolveDefaultBeeperAccountId(cfg) ?? "", + resolveAccount: (cfg: OpenClawSetupConfig, accountId?: string | null) => { + const resolvedAccountId = normalizeBeeperAccountId(accountId) ?? resolveDefaultBeeperAccountId(cfg); + return { + accountId: resolvedAccountId ?? "", + configured: resolvedAccountId ? isBeeperChannelConfigured(cfg, resolvedAccountId) : false, + settings: resolvedAccountId ? getBeeperAccountSettings(cfg, resolvedAccountId) : {}, + }; + }, + isEnabled: (account: { settings?: BeeperAccountSettings }) => account.settings?.enabled !== false, + isConfigured: (account: { configured?: boolean }) => account.configured === true, + hasConfiguredState: ({ cfg }: { cfg: OpenClawSetupConfig }) => isBeeperChannelConfigured(cfg), + describeAccount: (account: { accountId?: string; configured?: boolean; settings?: BeeperAccountSettings }) => ({ + accountId: "accountId" in account && typeof account.accountId === "string" ? account.accountId : "", + name: account.settings?.name ?? "Beeper", + configured: account.configured === true, + }), +}; + +export const beeperStatusAdapter = { + defaultRuntime: { + accountId: "", + configured: false, + enabled: false, + running: false, + }, + buildChannelSummary: ({ snapshot }: { snapshot: Record }) => ({ + connected: snapshot.running === true, + configured: snapshot.configured === true, + enabled: snapshot.enabled !== false, + homeserver: recordValue(snapshot.extra)?.homeserver, + running: snapshot.running === true, + }), + buildAccountSnapshot: ({ account, runtime }: { account: { accountId?: string; configured?: boolean; settings?: BeeperAccountSettings }; runtime?: Record }) => { + const settings = account.settings ?? {}; + return { + accountId: account.accountId ?? "", + configured: account.configured === true, + enabled: settings.enabled !== false, + extra: { + connected: runtime?.running === true, + homeserver: settings.bridge?.homeserver, + serverEnv: settings.serverEnv ?? "prod", + }, + name: "Beeper", + running: runtime?.running === true, + }; + }, + resolveAccountState: ({ configured, enabled }: { configured: boolean; enabled: boolean }) => { + if (!enabled) return "disabled"; + return configured ? "configured" : "not configured"; + }, + collectStatusIssues: (accounts: Array<{ configured?: boolean; enabled?: boolean }>) => + accounts + .filter((account) => account.enabled !== false && account.configured !== true) + .map((account) => ({ + accountId: "accountId" in account && typeof account.accountId === "string" ? account.accountId : "", + channel: BEEPER_CHANNEL_ID, + kind: "config" as const, + message: "Beeper is not connected; run Beeper setup with an existing Beeper account.", + severity: "warning" as const, + })), +}; + +const startedBridges = new Map(); + +export async function applyBeeperSetupConfig(params: { + accountId?: string; + cfg: OpenClawSetupConfig; + input: BeeperSetupInput; + runtime?: BeeperSetupRuntime; +}): Promise<{ accountId: string; cfg: OpenClawSetupConfig }> { + const baseSettings = normalizeBeeperSetupInput(params.input); + const requestedAccountId = normalizeBeeperAccountId(params.accountId); + if (!params.input.email && !params.input.username && !params.input.password) { + const accountId = requireBeeperAccountId(requestedAccountId); + return { accountId, cfg: applyBeeperAccountSettings(params.cfg, accountId, baseSettings) }; + } + const setupBridge = params.runtime?.setupBridge ?? (await loadBeeperSetupBridge()); + const bridgeOptions = setupOptionsFromInput(params.input); + const result = await setupBridge(bridgeOptions); + const setupSettings: Partial = { + ...baseSettings, + enabled: true, + }; + const bridgeSettings: BeeperGeneratedBridgeSettings = {}; + if (result.config.appserviceId) bridgeSettings.appserviceId = result.config.appserviceId; + if (result.config.asToken) setupSettings.asToken = result.config.asToken; + if (result.config.bridgeId) bridgeSettings.bridgeId = result.config.bridgeId; + if (result.config.homeserver) bridgeSettings.homeserver = result.config.homeserver; + if (result.config.homeserverDomain) bridgeSettings.homeserverDomain = result.config.homeserverDomain; + if (result.config.hsToken) setupSettings.hsToken = result.config.hsToken; + if (result.config.matrixDeviceId) bridgeSettings.matrixDeviceId = result.config.matrixDeviceId; + if (result.config.matrixUserId) bridgeSettings.matrixUserId = result.config.matrixUserId; + setupSettings.bridge = bridgeSettings; + const accountId = requestedAccountId ?? beeperAccountIdFromMatrixUserId(result.config.matrixUserId); + if (!accountId) throw new Error("Beeper setup did not return a Matrix user ID for the configured account."); + return { accountId, cfg: applyBeeperAccountSettings(params.cfg, accountId, setupSettings) }; +} + +async function loadBeeperSetupBridge(): Promise { + return (await import("./beeper-setup")).setupOpenClawBeeperBridge; +} + +export const BeeperChannelConfigSchemaForSdk = { + schema: BeeperChannelConfigSchema, + uiHints: BeeperChannelUiHints, +} as const; + +export const BeeperPluginConfigSchemaForSdk = { + schema: { + type: "object", + additionalProperties: false, + properties: {}, + }, +} as const; + +const BeeperChannelCapabilities: ChannelCapabilities = { + chatTypes: ["direct", "thread"], + blockStreaming: true, + media: true, + nativeCommands: true, + reactions: true, + threads: true, +}; + +type BeeperResolvedAccount = { + accountId: string; + configured: boolean; + settings: BeeperAccountSettings; +}; + +export const beeperChannelPlugin: ChannelPlugin & { uiHints: typeof BeeperChannelUiHints } = { + ...createChatChannelPlugin({ + base: { + ...createChannelPluginBase({ + id: BEEPER_CHANNEL_ID, + meta: { + id: BEEPER_CHANNEL_ID, + label: "Beeper", + selectionLabel: "Beeper agent DMs", + docsPath: "/channels/beeper", + docsLabel: "beeper", + blurb: "lets you chat with your OpenClaw agents on Beeper.", + order: 90, + }, + capabilities: BeeperChannelCapabilities, + reload: { configPrefixes: ["channels.beeper"] }, + commands: beeperCommandAdapter, + configSchema: BeeperChannelConfigSchemaForSdk, + config: beeperChannelConfig, + setup: beeperSetupAdapter, + setupWizard: beeperSetupWizard, + agentPrompt: beeperAgentPromptAdapter, + }), + capabilities: BeeperChannelCapabilities, + config: beeperChannelConfig, + setup: beeperSetupAdapter, + status: beeperStatusAdapter, + conversationBindings: beeperConversationBindings, + message: beeperMessageAdapter, + messaging: beeperMessagingAdapter, + outbound: beeperOutboundAdapter, + directory: beeperDirectoryAdapter, + resolver: beeperResolverAdapter, + heartbeat: beeperHeartbeatAdapter, + approvalCapability: beeperApprovalCapability, + actions: beeperMessageActions, + bindings: { + selfParentConversationByDefault: true, + compileConfiguredBinding: ({ conversationId }: { conversationId: string }) => ({ conversationId }), + matchInboundConversation: ({ compiledBinding, conversationId }: { compiledBinding: { conversationId: string }; conversationId: string }) => + compiledBinding.conversationId === conversationId ? compiledBinding : null, + resolveCommandConversation: ({ originatingTo, commandTo, fallbackTo }: { + originatingTo?: string; + commandTo?: string; + fallbackTo?: string; + }) => { + const conversationId = commandTo ?? originatingTo ?? fallbackTo; + return conversationId ? { conversationId } : null; + }, + }, + gateway: { + startAccount: startBeeperGatewayAccount, + stopAccount: stopBeeperGatewayAccount, + }, + }, + threading: { topLevelReplyToMode: "reply" }, + }), + uiHints: BeeperChannelUiHints, +}; + +export type BeeperChannelPlugin = typeof beeperChannelPlugin; + +function stripUndefined>(input: T): T { + for (const key of Object.keys(input)) { + if (input[key] === undefined) delete input[key]; + } + return input; +} + +function normalizeBeeperMessagingTarget(raw: string | undefined): string | undefined { + const trimmed = raw?.trim(); + if (!trimmed) return undefined; + return trimmed + .replace(/^beeper:/iu, "") + .replace(/^agent:/iu, "") + .replace(/^openclaw:/iu, "") + .trim() || undefined; +} + +function normalizeBeeperConversationId(raw: string | undefined): string | undefined { + const normalized = normalizeBeeperMessagingTarget(raw); + if (!normalized) return undefined; + if (normalized.startsWith("room:")) return normalized.slice("room:".length) || undefined; + return normalized; +} + +function formatBeeperTargetDisplay(target: string): string { + const normalized = normalizeBeeperMessagingTarget(target) ?? target; + if (normalized.startsWith("@")) return normalized; + if (normalized.startsWith("!")) return normalized; + return `@${normalized}`; +} + +function resolveBeeperRoomTarget(target: string): string { + const normalized = normalizeBeeperConversationId(target); + if (!normalized) throw new Error("Beeper target is required."); + return normalized; +} + +function readRequiredBeeperRoomId(params: Record, ...keys: string[]): string { + return resolveBeeperRoomTarget(readRequiredString(params, ...(keys.length > 0 ? keys : ["roomId"]))); +} + +function isBeeperMessageToolAction(action: string): action is BeeperMessageToolAction { + return (beeperMessageToolActions as readonly string[]).includes(action); +} + +function beeperOutboundResult(sent: { eventId: string; roomId: string }): { + channel: string; + messageId: string; + conversationId: string; +} { + return { + channel: BEEPER_CHANNEL_ID, + conversationId: sent.roomId, + messageId: sent.eventId, + }; +} + +function beeperMessageSendResult(result: { messageId: string; conversationId?: string }): { + messageId: string; + receipt: { + platformMessageIds: string[]; + parts: []; + sentAt: number; + }; + raw: unknown; +} { + return { + messageId: result.messageId, + receipt: { + platformMessageIds: [result.messageId], + parts: [], + sentAt: Date.now(), + }, + raw: result, + }; +} + +function firstPayloadText(payload: unknown): string | undefined { + const record = recordValue(payload); + return stringValue(record?.text) + ?? stringValue(record?.body) + ?? stringValue(record?.message) + ?? stringValue(recordValue(record?.content)?.text); +} + +function firstPayloadMediaUrl(payload: unknown): string | undefined { + const record = recordValue(payload); + const media = record?.media ?? record?.mediaUrl ?? record?.filePath ?? record?.path; + if (typeof media === "string") return media; + if (Array.isArray(media)) return media.find((item): item is string => typeof item === "string"); + return undefined; +} + +function readRequiredString(params: Record, ...keys: string[]): string { + for (const key of keys) { + const value = stringValue(params[key]); + if (value) return value; + } + throw new Error(`Missing required Beeper action parameter: ${keys.join(" or ")}`); +} + +function readOptionalString(params: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = stringValue(params[key]); + if (value) return value; + } + return undefined; +} + +function stringifyOptional(value: string | number | null | undefined): string | undefined { + return value == null ? undefined : String(value); +} + +function listConfiguredAgentDirectoryEntries( + cfg: OpenClawSetupConfig, + query?: string | null, + limit?: number | null, +): Array<{ kind: "user"; id: string; name?: string; handle?: string; raw?: unknown }> { + const agents = recordValue(cfg)?.agents; + const list = recordValue(agents)?.list; + if (!Array.isArray(list)) return []; + const normalizedQuery = query?.trim().toLowerCase(); + return list.flatMap((agent) => { + const record = recordValue(agent); + const id = stringValue(record?.id) ?? stringValue(record?.name); + if (!id) return []; + const name = stringValue(record?.displayName) ?? stringValue(record?.name) ?? id; + const haystack = `${id} ${name}`.toLowerCase(); + if (normalizedQuery && !haystack.includes(normalizedQuery)) return []; + return [stripUndefined({ + handle: id, + id, + kind: "user" as const, + name, + raw: agent, + })]; + }).slice(0, limit ?? 100); +} + +async function listSavedAgentDirectoryEntries( + cfg: OpenClawSetupConfig, + query?: string | null, + limit?: number | null, +): Promise> { + try { + const config = createConfigFromOpenClawSetup(cfg); + const registry = new OpenClawBridgeRegistry(defaultRegistryPath(config.dataDir)); + await registry.load(); + return listAgentContactsDirectoryEntries(registry.data.agents, query, limit); + } catch { + return []; + } +} + +function listAgentContactsDirectoryEntries( + agents: readonly { agentId?: string; avatarMxc?: string; description?: string; displayName?: string; ghostUserId?: string }[], + query?: string | null, + limit?: number | null, +): Array<{ kind: "user"; id: string; name?: string; handle?: string; avatarUrl?: string; description?: string; raw?: unknown }> { + const normalizedQuery = query?.trim().toLowerCase(); + return agents.flatMap((agent) => { + const agentRecord = recordValue(agent); + const id = agent.agentId ?? stringValue(agentRecord?.id); + if (!id) return []; + const name = agent.displayName ?? stringValue(agentRecord?.displayName) ?? stringValue(agentRecord?.name) ?? id; + const avatarUrl = agent.avatarMxc ?? stringValue(agentRecord?.avatarMxc) ?? stringValue(agentRecord?.avatarUrl); + const description = agent.description ?? stringValue(agentRecord?.description); + const haystack = `${id} ${name} ${description ?? ""}`.toLowerCase(); + if (normalizedQuery && !haystack.includes(normalizedQuery)) return []; + const entry = stripUndefined({ + ...(avatarUrl ? { avatarUrl } : {}), + ...(description ? { description } : {}), + handle: id, + id, + kind: "user" as const, + name, + raw: agent, + }); + return [entry]; + }).slice(0, limit ?? 100); +} + +async function listLiveOrConfiguredAgentDirectoryEntries( + cfg: OpenClawSetupConfig, + query?: string | null, + limit?: number | null, +): Promise> { + const runtimeAgents = (() => { + try { + return requireBeeperChannelRuntime().listAgents(); + } catch { + return []; + } + })(); + if (runtimeAgents.length > 0) return listAgentContactsDirectoryEntries(runtimeAgents, query, limit); + const savedAgents = await listSavedAgentDirectoryEntries(cfg, query, limit); + if (savedAgents.length > 0) return savedAgents; + return listConfiguredAgentDirectoryEntries(cfg, query, limit); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext | ChannelGatewayContext<{ accountId: string; configured: boolean; settings: BeeperChannelSettings }>): Promise { + const key = gatewayAccountKey(ctx.accountId); + const existing = startedBridges.get(key); + if (existing) { + if ("promise" in existing) return existing.promise; + ctx.setStatus?.({ + accountId: ctx.accountId, + configured: true, + enabled: true, + running: true, + }); + await waitForAbort(ctx.abortSignal); + return; + } + const promise = startBeeperGatewayAccountOnce(ctx, key); + startedBridges.set(key, { promise }); + try { + await promise; + } finally { + const current = startedBridges.get(key); + if (current && "promise" in current && current.promise === promise) startedBridges.delete(key); + } +} + +async function startBeeperGatewayAccountOnce(ctx: BeeperGatewayContext | ChannelGatewayContext<{ accountId: string; configured: boolean; settings: BeeperChannelSettings }>, key: string): Promise { + try { + ctx.log?.info?.("Beeper bridge startup beginning."); + const settings = getBeeperAccountSettings(ctx.cfg, ctx.accountId); + if (settings.enabled === false) { + ctx.log?.info?.("Beeper bridge is disabled; skipping startup."); + return; + } + if (!isBeeperChannelConfigured(ctx.cfg, ctx.accountId)) { + throw new Error(`Beeper account "${ctx.accountId}" is not fully configured; run Beeper channel setup first.`); + } + const { startOpenClawBeeperBridge } = await import("./appservice"); + const config = await createRuntimeConfigFromOpenClawSetup(ctx.cfg, {}, ctx.accountId); + const hostRuntime = resolveBeeperHostRuntime(ctx); + const statusSink = (patch: { + lastEventAt?: number; + lastInboundAt?: number; + lastOutboundAt?: number; + lastTransportActivityAt?: number; + }) => { + ctx.setStatus?.({ + accountId: ctx.accountId, + ...patch, + }); + }; + const bridge = await startOpenClawBeeperBridge({ + config, + dataDir: config.dataDir, + log: bridgeLoggerFromChannelContext(ctx), + onActivity: statusSink, + ...(hostRuntime ? { runtime: hostRuntime } : {}), + }); + if (hostRuntime && openClawPluginRuntime && hostRuntime !== openClawPluginRuntime) { + setBeeperChannelRuntimeForHost(openClawPluginRuntime, requireBeeperChannelRuntimeForHost(hostRuntime)); + } + startedBridges.set(key, bridge as StartedBeeperBridge); + ctx.setStatus?.({ + accountId: ctx.accountId, + configured: true, + enabled: true, + running: true, + }); + ctx.log?.info?.("Beeper bridge started."); + try { + await waitForAbort(ctx.abortSignal); + } finally { + startedBridges.delete(key); + if (hostRuntime && openClawPluginRuntime && hostRuntime !== openClawPluginRuntime) { + setBeeperChannelRuntimeForHost(openClawPluginRuntime, undefined); + } + await bridge.stop?.(); + ctx.setStatus?.({ + accountId: ctx.accountId, + running: false, + }); + ctx.log?.info?.("Beeper bridge stopped."); + } + } catch (error) { + ctx.log?.error?.(`Beeper bridge startup failed: ${formatStartupError(error)}`); + throw error; + } +} + +function bridgeLoggerFromChannelContext(ctx: BeeperGatewayContext): BridgeLogger { + return (level, message, data) => { + const logger = level === "error" ? ctx.log?.error + : level === "warn" ? ctx.log?.warn + : ctx.log?.info; + logger?.(data === undefined ? `[pickle-bridge] ${message}` : `[pickle-bridge] ${message} ${formatBridgeLogData(data)}`); + }; +} + +function formatBridgeLogData(data: unknown): string { + if (typeof data === "string") return data; + try { + return JSON.stringify(data); + } catch { + return String(data); + } +} + +function formatStartupError(error: unknown): string { + if (!(error instanceof Error)) return String(error); + return error.stack ?? error.message; +} + +function resolveBeeperHostRuntime(ctx: BeeperGatewayContext): OpenClawHostRuntime | undefined { + if (ctx.hostRuntime && typeof ctx.hostRuntime === "object" && hasOpenClawSessionRuntime(ctx.hostRuntime)) return ctx.hostRuntime; + if (ctx.channelRuntime && typeof ctx.channelRuntime === "object" && hasOpenClawChannelRuntime(ctx.channelRuntime)) { + const channel: NonNullable = ctx.channelRuntime; + const runtime = (openClawPluginRuntime ?? (ctx.runtime && typeof ctx.runtime === "object" ? ctx.runtime : {})) as OpenClawHostRuntime; + return { + ...runtime, + channel, + config: { + ...runtime.config, + current: runtime.config?.current ?? (() => ctx.cfg), + }, + }; + } + if (openClawPluginRuntime && hasOpenClawSessionRuntime(openClawPluginRuntime)) return withConfigFallback(openClawPluginRuntime, ctx.cfg); + if (ctx.runtime && typeof ctx.runtime === "object" && hasOpenClawSessionRuntime(ctx.runtime)) return withConfigFallback(ctx.runtime, ctx.cfg); + return undefined; +} + +function withConfigFallback(runtime: object, cfg: OpenClawSetupConfig): OpenClawHostRuntime { + const hostRuntime = runtime as OpenClawHostRuntime; + return { + ...hostRuntime, + config: { + ...hostRuntime.config, + current: hostRuntime.config?.current ?? (() => cfg), + }, + }; +} + +function hasOpenClawSessionRuntime(value: object): value is OpenClawHostRuntime { + if (hasOpenClawChannelRuntime((value as { channel?: unknown }).channel)) return true; + const agent = (value as { agent?: unknown }).agent; + if (!agent || typeof agent !== "object") return false; + const session = (agent as { session?: unknown }).session; + if (!session || typeof session !== "object") return false; + return typeof (session as { listSessionEntries?: unknown }).listSessionEntries === "function" + || typeof (session as { getSessionEntry?: unknown }).getSessionEntry === "function"; +} + +function hasOpenClawChannelRuntime(value: unknown): value is NonNullable { + if (!value || typeof value !== "object") return false; + const channel = value as NonNullable; + return typeof channel.inbound?.buildContext === "function" + && typeof channel.inbound.dispatchReply === "function" + && typeof channel.session?.recordInboundSession === "function" + && typeof channel.reply?.dispatchReplyWithBufferedBlockDispatcher === "function"; +} + +export async function stopBeeperGatewayAccount(ctx: BeeperGatewayContext | ChannelGatewayContext<{ accountId: string; configured: boolean; settings: BeeperChannelSettings }>): Promise { + const bridge = startedBridges.get(gatewayAccountKey(ctx.accountId)); + if (!bridge || "promise" in bridge) return; + startedBridges.delete(gatewayAccountKey(ctx.accountId)); + await bridge.stop?.(); + ctx.setStatus?.({ + accountId: ctx.accountId, + running: false, + }); +} + +export function getBeeperChannelSettings(cfg: OpenClawSetupConfig): BeeperChannelSettings { + const channelSettings = recordValue(cfg.channels?.[BEEPER_CHANNEL_ID]); + return (channelSettings as BeeperChannelSettings | undefined) ?? {}; +} + +export function getBeeperAccountSettings(cfg: OpenClawSetupConfig, accountId?: string | null): BeeperAccountSettings { + const channelSettings = getBeeperChannelSettings(cfg); + const normalized = normalizeBeeperAccountId(accountId) ?? resolveDefaultBeeperAccountId(cfg); + if (!normalized) return {}; + return { ...(getNamedBeeperAccountSettings(channelSettings, normalized) ?? {}) }; +} + +export function listBeeperAccountIds(cfg: OpenClawSetupConfig): string[] { + const settings = getBeeperChannelSettings(cfg); + const ids = new Set(); + for (const id of Object.keys(settings.accounts ?? {})) { + const normalized = normalizeBeeperAccountId(id); + if (normalized) ids.add(normalized); + } + return [...ids]; +} + +export function resolveDefaultBeeperAccountId(cfg: OpenClawSetupConfig): string | undefined { + const settings = getBeeperChannelSettings(cfg); + const configured = normalizeBeeperAccountId(settings.defaultAccount); + if (configured && listBeeperAccountIds(cfg).includes(configured)) return configured; + return listBeeperAccountIds(cfg)[0]; +} + +export function resolveBeeperAgentAccountId(cfg: OpenClawSetupConfig, agentId: string, requestedAccountId?: string | null): string { + const requested = normalizeBeeperAccountId(requestedAccountId); + const accountIds = listBeeperAccountIds(cfg); + const agentSettings = getBeeperChannelSettings(cfg).agents?.[agentId]; + const allowed = (agentSettings?.accountIds ?? []).map(normalizeBeeperAccountId).filter((id): id is string => Boolean(id)); + if (requested) { + if (allowed.length > 0 && !allowed.includes(requested)) { + throw new Error(`Beeper account "${requested}" is not assigned to agent "${agentId}".`); + } + return requested; + } + const preferred = normalizeBeeperAccountId(agentSettings?.defaultAccount); + if (preferred && (allowed.length === 0 || allowed.includes(preferred)) && accountIds.includes(preferred)) return preferred; + const firstAllowed = allowed.find((id) => accountIds.includes(id)); + if (firstAllowed) return firstAllowed; + const defaultAccountId = resolveDefaultBeeperAccountId(cfg); + if (!defaultAccountId) throw new Error("No Beeper accounts are configured."); + return defaultAccountId; +} + +export function isBeeperChannelConfigured(cfg: OpenClawSetupConfig, accountId?: string | null): boolean { + if (!accountId) return listBeeperAccountIds(cfg).some((id) => isBeeperChannelConfigured(cfg, id)); + const settings = getBeeperAccountSettings(cfg, accountId); + const bridge = settings.bridge; + return Boolean( + settings.enabled !== false && + hasConfiguredSecretInput(settings.asToken, cfg.secrets?.defaults) && + bridge?.homeserver && + hasConfiguredSecretInput(settings.hsToken, cfg.secrets?.defaults) && + bridge.matrixDeviceId && + bridge.matrixUserId + ); +} + +export function applyBeeperChannelSettings( + cfg: OpenClawSetupConfig, + patch: Partial, +): OpenClawSetupConfig { + const current = getBeeperChannelSettings(cfg); + const nextSettings = { + ...current, + ...patch, + }; + return { + ...cfg, + channels: { + ...cfg.channels, + [BEEPER_CHANNEL_ID]: nextSettings, + }, + }; +} + +export function applyBeeperAccountSettings( + cfg: OpenClawSetupConfig, + accountId: string, + patch: Partial, +): OpenClawSetupConfig { + const normalized = requireBeeperAccountId(accountId); + const current = getBeeperChannelSettings(cfg); + return applyBeeperChannelSettings(cfg, { + accounts: { + ...(current.accounts ?? {}), + [normalized]: { + ...(getNamedBeeperAccountSettings(current, normalized) ?? {}), + ...patch, + }, + }, + defaultAccount: current.defaultAccount ?? normalized, + }); +} + +export function defaultBeeperAccountSettings(): BeeperAccountSettings { + return { + dataDir: defaultDataDir(), + enabled: true, + serverEnv: "prod", + }; +} + +export function validateBeeperSetupInput(input: BeeperSetupInput): string | null { + const authMethods = [input.email, input.username || input.password].filter(Boolean).length; + if (authMethods > 1) return "Choose only one Beeper login method."; + if (input.email !== undefined && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/u.test(input.email)) return "Beeper email must be a valid email address."; + if (input.username !== undefined && !input.username.trim()) return "Beeper username is required."; + if (input.password !== undefined && !input.password.trim()) return "Beeper password is required."; + if ((input.username && !input.password) || (input.password && !input.username)) return "Beeper username/password login requires both username and password."; + if (input.serverEnv !== undefined && normalizeServerEnv(input.serverEnv) === undefined) return "Beeper server environment must be prod, staging, dev, or local."; + return null; +} + +export function normalizeBeeperSetupInput(input: BeeperSetupInput): Partial { + const settings: Partial = { enabled: true }; + const serverEnv = normalizeServerEnv(input.serverEnv); + if (serverEnv) settings.serverEnv = serverEnv; + if (input.dataDir) settings.dataDir = input.dataDir; + return settings; +} + +export function setupOptionsFromInput(input: BeeperSetupInput): SetupOpenClawBeeperBridgeOptions { + const validation = validateBeeperSetupInput(input); + if (validation) throw new Error(validation); + if (!input.email && !input.username && !input.password) throw new Error("Beeper email or username/password is required for setup"); + const options: SetupOpenClawBeeperBridgeOptions = {}; + if (input.email) options.email = input.email; + if (input.username) options.username = input.username; + if (input.password) options.password = input.password; + const env = normalizeServerEnv(input.serverEnv); + if (env) options.env = env; + if (input.getLoginCode) options.getLoginCode = input.getLoginCode; + return options; +} + +function normalizeServerEnv(value: string | undefined): BeeperAccountSettings["serverEnv"] | undefined { + if (value === "prod" || value === "staging" || value === "dev" || value === "local") return value; + return undefined; +} + +function getNamedBeeperAccountSettings(settings: BeeperChannelSettings, accountId: string): BeeperAccountSettings | undefined { + const direct = recordValue(settings.accounts?.[accountId]) as BeeperAccountSettings | undefined; + if (direct) return direct; + if (accountId.startsWith("@")) return recordValue(settings.accounts?.[accountId.slice(1)]) as BeeperAccountSettings | undefined; + if (accountId.includes(":")) return recordValue(settings.accounts?.[`@${accountId}`]) as BeeperAccountSettings | undefined; + return undefined; +} + +function resolveSetupStatusAccountId(cfg: OpenClawSetupConfig, accountId?: string | null): string { + const normalized = normalizeBeeperAccountId(accountId); + if (normalized) return normalized; + const accountIds = listBeeperAccountIds(cfg); + const configured = accountIds.find((id) => isBeeperChannelConfigured(cfg, id)); + return configured ?? resolveDefaultBeeperAccountId(cfg) ?? ""; +} + +function disableAllBeeperAccounts(cfg: OpenClawSetupConfig): OpenClawSetupConfig { + const current = getBeeperChannelSettings(cfg); + const accounts = Object.fromEntries( + listBeeperAccountIds(cfg).map((accountId) => [ + accountId, + { + ...getBeeperAccountSettings(cfg, accountId), + enabled: false, + }, + ]), + ); + return applyBeeperChannelSettings(cfg, { accounts }); +} + +function beeperSetupRuntime(value: unknown): BeeperSetupRuntime | undefined { + const record = recordValue(value); + if (typeof record?.setupBridge !== "function") return undefined; + const setupBridge = record.setupBridge as NonNullable; + return { setupBridge }; +} + +function gatewayAccountKey(accountId: string): string { + return requireBeeperAccountId(accountId); +} + +function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} + +function recordValue(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} diff --git a/packages/openclaw/src/types.ts b/packages/openclaw/src/types.ts new file mode 100644 index 0000000..351ca7e --- /dev/null +++ b/packages/openclaw/src/types.ts @@ -0,0 +1,74 @@ +import type { SecretInput } from "openclaw/plugin-sdk/secret-input-runtime"; + +export type BeeperServerEnv = "prod" | "staging" | "dev" | "local"; + +export interface OpenClawAgentContact { + agentId: string; + displayName: string; + ghostUserId: string; + avatarMxc?: string; + avatarUrl?: string; + description?: string; +} + +export interface OpenClawBeeperChannelInfo { + agent?: OpenClawAgentContact; + binding?: OpenClawSessionBinding; + portalKey?: { id: string; receiver?: string }; + roomId: string; +} + +export interface OpenClawSessionBinding { + id: string; + roomId: string; + spaceId?: string; + sessionKey?: string; + agentId: string; + ghostUserId: string; + humanGhostUserId?: string; + cwd?: string; + label?: string; + createdAt: number; + updatedAt: number; + lastRunId?: string; + lastMatrixEventId?: string; + lastStreamRunId?: string; + lastStreamTargetEventId?: string; +} + +export interface OpenClawBridgeConfig { + asToken?: string; + appserviceId: string; + bridgeId?: string; + dataDir: string; + homeserver?: string; + hsToken?: string; + homeserverDomain?: string; + matrixDeviceId?: string; + matrixUserId?: string; + serverEnv?: BeeperServerEnv; +} + +export type OpenClawBridgeSecretInput = SecretInput; + +export interface OpenClawBridgeRegistryData { + agents: OpenClawAgentContact[]; + bindings: OpenClawSessionBinding[]; + dedupe: Record; + schemaVersion: 1; +} + +export interface AppserviceRegistration { + as_token: string; + hs_token: string; + id: string; + namespaces: { + aliases: Array<{ exclusive: boolean; regex: string }>; + rooms: Array<{ exclusive: boolean; regex: string }>; + users: Array<{ exclusive: boolean; regex: string }>; + }; + receive_ephemeral: boolean; + rate_limited: boolean; + sender_localpart: string; + url: string; +} diff --git a/packages/openclaw/tsconfig.json b/packages/openclaw/tsconfig.json new file mode 100644 index 0000000..39b47ed --- /dev/null +++ b/packages/openclaw/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts new file mode 100644 index 0000000..022da36 --- /dev/null +++ b/packages/openclaw/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + clean: true, + deps: { + alwaysBundle: [/^@beeper\//], + }, + dts: true, + entry: ["src/account-id.ts", "src/approval.ts", "src/appservice.ts", "src/auth-presence.ts", "src/beeper-channel-runtime.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/matrix-parser.ts", "src/openclaw-runtime.ts", "src/plugin-entry.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/secret-contract.ts", "src/serial.ts", "src/setup.ts", "src/setup-entry.ts", "src/types.ts"], + format: ["esm"], +}); diff --git a/packages/openclaw/vitest.config.ts b/packages/openclaw/vitest.config.ts new file mode 100644 index 0000000..f4a6f68 --- /dev/null +++ b/packages/openclaw/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + resolve: { + alias: [ + { find: "@beeper/pickle-bridge/beeper", replacement: new URL("../bridge/src/beeper.ts", import.meta.url).pathname }, + { find: "@beeper/pickle-bridge/beeper-stream", replacement: new URL("../bridge/src/beeper-stream.ts", import.meta.url).pathname }, + { find: "@beeper/pickle-bridge/bridge", replacement: new URL("../bridge/src/bridge.ts", import.meta.url).pathname }, + { find: "@beeper/pickle-bridge/events", replacement: new URL("../bridge/src/events.ts", import.meta.url).pathname }, + { find: "@beeper/pickle-bridge/media-message", replacement: new URL("../bridge/src/media-message.ts", import.meta.url).pathname }, + { find: "@beeper/pickle-bridge/node", replacement: new URL("../bridge/src/node.ts", import.meta.url).pathname }, + { find: "@beeper/pickle-bridge/types", replacement: new URL("../bridge/src/types.ts", import.meta.url).pathname }, + { find: /^@beeper\/pickle-ag-ui$/, replacement: new URL("../ag-ui/src/index.ts", import.meta.url).pathname }, + { find: /^@beeper\/pickle-state-file$/, replacement: new URL("../state-file/src/index.ts", import.meta.url).pathname }, + ], + }, + test: { + coverage: { + include: ["src/**/*.ts"], + provider: "v8", + reporter: ["text", "json-summary"], + }, + environment: "node", + }, +}); diff --git a/packages/pi/LICENSE b/packages/pi/LICENSE new file mode 100644 index 0000000..d04286b --- /dev/null +++ b/packages/pi/LICENSE @@ -0,0 +1,8 @@ +Mozilla Public License Version 2.0 +================================== + +This package is licensed under the Mozilla Public License, version 2.0. + +The full license text is available at: + +https://www.mozilla.org/MPL/2.0/ diff --git a/packages/pi/package.json b/packages/pi/package.json index b16004d..857359d 100644 --- a/packages/pi/package.json +++ b/packages/pi/package.json @@ -71,6 +71,7 @@ "scripts": { "build": "tsdown", "clean": "rm -rf dist", + "prepublishOnly": "node ../../scripts/guard-pnpm-publish.mjs", "test": "vitest run --coverage", "typecheck": "tsc --noEmit" }, diff --git a/packages/pickle/native/go.mod b/packages/pickle/native/go.mod index fb0ffc7..9089c3d 100644 --- a/packages/pickle/native/go.mod +++ b/packages/pickle/native/go.mod @@ -3,30 +3,37 @@ module github.com/beeper/pickle/packages/pickle/native go 1.25.0 require ( - github.com/beeper/ai-bridge v0.0.0-20260524021151-5c8086351a72 + github.com/beeper/ai-bridge v0.0.0-20260602153000-75057637d3ab github.com/gzuidhof/tygo v0.2.21 - maunium.net/go/mautrix v0.27.1-0.20260422171355-c6fe96e2dea3 + maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 ) require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/fatih/structtag v1.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.42 // indirect + github.com/mattn/go-sqlite3 v1.14.44 // indirect github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect - github.com/rs/zerolog v1.35.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/rs/zerolog v1.35.1 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - go.mau.fi/util v0.9.8 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.53.0 // indirect + github.com/yuin/goldmark v1.8.2 // indirect + go.mau.fi/util v0.9.9 // indirect + go.mau.fi/zeroconfig v0.2.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.54.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.45.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/packages/pickle/native/go.sum b/packages/pickle/native/go.sum index a956fe5..447d8ff 100644 --- a/packages/pickle/native/go.sum +++ b/packages/pickle/native/go.sum @@ -2,8 +2,12 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/beeper/ai-bridge v0.0.0-20260524021151-5c8086351a72 h1:Pw2qyz5mizv/UL4JTKiK1sbYfUl6o8dk/KcNyFlSFG0= -github.com/beeper/ai-bridge v0.0.0-20260524021151-5c8086351a72/go.mod h1:Uf2M1ogzy7VGB6uUzzHjZL2eaYt79DK0Py8I6xZl3r0= +github.com/beeper/ai-bridge v0.0.0-20260602153000-75057637d3ab h1:Vs4NbdfxAkSexNR0fwDCMrCK/XIlmHGuV1Bj+p40olo= +github.com/beeper/ai-bridge v0.0.0-20260602153000-75057637d3ab/go.mod h1:+icZV4D9wnp0NTP8bsfS/WXrf/8plzmnp/3bhQEnL3E= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= @@ -16,14 +20,16 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= -github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= -github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -36,26 +42,34 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -go.mau.fi/util v0.9.8 h1:+/jf8eM2dAT2wx9UidmaneH28r/CSCKCniCyby1qWz8= -go.mau.fi/util v0.9.8/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE= +go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY= +go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= +go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maunium.net/go/mautrix v0.27.1-0.20260422171355-c6fe96e2dea3 h1:V5L7Yo0fH1fs6lybfR+BUWG1D25xIdUZNWBIPXCV8cY= -maunium.net/go/mautrix v0.27.1-0.20260422171355-c6fe96e2dea3/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs= +maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 h1:zNC9eVAhw8FhKpM3AxNAh/iy75UEYX91uJUvqqAYlvo= +maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4/go.mod h1:3sOGhXi3P1V6/NruTA0gujkvTypXVUraWktCuTGyDuM= diff --git a/packages/pickle/native/internal/core/appservice.go b/packages/pickle/native/internal/core/appservice.go index 2ad6bef..77dbbfe 100644 --- a/packages/pickle/native/internal/core/appservice.go +++ b/packages/pickle/native/internal/core/appservice.go @@ -66,6 +66,19 @@ type MatrixAppserviceRoomUserOptions struct { UserID string `json:"userId"` } +type MatrixAppserviceSetProfileOptions struct { + AvatarURL *string `json:"avatarUrl,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + Extra OutboundEvent `json:"extra,omitempty" tstype:"{ [key: string]: unknown }"` + Identifiers []string `json:"identifiers,omitempty"` + IsBridgeBot *bool `json:"isBridgeBot,omitempty"` + IsNetworkBot *bool `json:"isNetworkBot,omitempty"` + Network string `json:"network,omitempty"` + RemoteID string `json:"remoteId,omitempty"` + Service string `json:"service,omitempty"` + UserID string `json:"userId"` +} + type MatrixAppserviceCreateRoomOptions struct { MatrixCreateRoomOptions UserID string `json:"userId,omitempty"` @@ -91,6 +104,7 @@ type MatrixAppserviceCreatePortalRoomOptions struct { AutoJoinInvites bool `json:"autoJoinInvites,omitempty"` Bridge MatrixAppserviceBridgeName `json:"bridge"` BridgeName string `json:"bridgeName,omitempty"` + CreationContent map[string]any `json:"creationContent,omitempty" tstype:"{ [key: string]: unknown }"` InitialState []MatrixRoomStateInput `json:"initialState,omitempty"` InitialMembers []string `json:"initialMembers,omitempty"` Invite []string `json:"invite,omitempty"` @@ -150,8 +164,11 @@ type MatrixAppserviceTransactionOptions struct { } type matrixAppserviceTransaction struct { - Events []*event.Event `json:"events"` - ToDeviceEvents []*event.Event `json:"to_device,omitempty"` + AccountData []*event.Event `json:"account_data,omitempty"` + EphemeralEvents []*event.Event `json:"ephemeral,omitempty"` + Events []*event.Event `json:"events"` + RoomAccountData []*event.Event `json:"room_account_data,omitempty"` + ToDeviceEvents []*event.Event `json:"to_device,omitempty"` } type beeperStreamEventProcessor struct { @@ -227,21 +244,64 @@ func (c *Core) handleAppserviceApplyTransaction(ctx context.Context, payload []b Int("to_device_events", len(txn.ToDeviceEvents)). Msg("Applying appservice transaction") } - c.dispatchAppserviceEvents(ctx, txn.Events, event.MessageEventType) - c.dispatchAppserviceEvents(ctx, txn.ToDeviceEvents, event.ToDeviceEventType) + c.dispatchAppserviceEvents(ctx, txn.Events, "appservice_events") + c.dispatchAppserviceMetadata(ctx, txn.EphemeralEvents, "appservice_ephemeral", "") + c.dispatchAppserviceMetadata(ctx, txn.AccountData, "appservice_account_data", "") + c.dispatchAppserviceMetadata(ctx, txn.RoomAccountData, "appservice_room_account_data", "") + c.dispatchAppserviceToDeviceEvents(ctx, txn.ToDeviceEvents) return c.empty() } -func (c *Core) dispatchAppserviceEvents(ctx context.Context, events []*event.Event, class event.TypeClass) { +func (c *Core) dispatchAppserviceEvents(ctx context.Context, events []*event.Event, section string) { for _, evt := range events { if evt == nil { continue } - evt.Type.Class = class + evt.Type.Class = classifyAppserviceEventClass(evt.Type) + if evt.Type == event.EventMessage || evt.Type == event.EventReaction || evt.Type == event.EventRedaction || evt.Type == event.EventEncrypted { + c.processEvent(ctx, evt) + continue + } + if c.emit != nil { + roomID := evt.RoomID + c.emitClassifiedRoomEvent(section, roomID, evt, "", "") + } + } +} + +func (c *Core) dispatchAppserviceMetadata(ctx context.Context, events []*event.Event, section string, defaultClass string) { + _ = ctx + for _, evt := range events { + if evt == nil || c.emit == nil { + continue + } + class := defaultClass + if class == "" { + class = "ephemeral" + switch evt.Type { + case event.EphemeralEventReceipt: + class = "receipt" + case event.EphemeralEventTyping: + class = "typing" + } + if section == "appservice_account_data" || section == "appservice_room_account_data" { + class = "accountData" + } + } + c.emitSyncEvent(section, class, evt.RoomID, evt, "", "") + } +} + +func (c *Core) dispatchAppserviceToDeviceEvents(ctx context.Context, events []*event.Event) { + for _, evt := range events { + if evt == nil { + continue + } + evt.Type.Class = event.ToDeviceEventType if err := evt.Content.ParseRaw(evt.Type); err != nil && c.client != nil && (evt.Type == event.ToDeviceBeeperStreamSubscribe || evt.Type == event.ToDeviceEncrypted || evt.Type == event.ToDeviceBeeperStreamUpdate) { c.client.Log.Debug().Err(err).Str("event_type", evt.Type.Type).Msg("Failed to parse appservice stream event content") } - if c.client != nil && class == event.ToDeviceEventType && (evt.Type == event.ToDeviceBeeperStreamSubscribe || evt.Type == event.ToDeviceEncrypted || evt.Type == event.ToDeviceBeeperStreamUpdate) { + if c.client != nil && (evt.Type == event.ToDeviceBeeperStreamSubscribe || evt.Type == event.ToDeviceEncrypted || evt.Type == event.ToDeviceBeeperStreamUpdate) { subscribe := evt.Content.AsBeeperStreamSubscribe() encrypted := evt.Content.AsEncrypted() c.client.Log.Debug(). @@ -255,10 +315,24 @@ func (c *Core) dispatchAppserviceEvents(ctx context.Context, events []*event.Eve Str("encrypted_stream_id", encrypted.StreamID). Msg("Dispatching appservice stream to-device event") } + if c.emit != nil { + c.emitSyncEvent("appservice_to_device", "toDevice", "", evt, "", "") + } c.appserviceProcessor.Dispatch(ctx, evt) } } +func classifyAppserviceEventClass(evtType event.Type) event.TypeClass { + switch evtType.Type { + case event.StateMember.Type, event.StateRoomName.Type, event.StateTopic.Type, event.StateRoomAvatar.Type, event.StateEncryption.Type: + return event.StateEventType + case event.EventRedaction.Type, event.EventMessage.Type, event.EventReaction.Type, event.EventEncrypted.Type: + return event.MessageEventType + default: + return evtType.Class + } +} + func (c *Core) handleAppserviceEnsureRegistered(ctx context.Context, payload []byte) ([]byte, error) { intent, _, err := c.appserviceIntent(payload) if err != nil { @@ -279,6 +353,78 @@ func (c *Core) handleAppserviceEnsureJoined(ctx context.Context, payload []byte) return c.emptyIfNil(c.appservice.ensureJoined(ctx, intent, id.RoomID(req.RoomID))) } +func (c *Core) handleAppserviceSetProfile(ctx context.Context, payload []byte) ([]byte, error) { + var req MatrixAppserviceSetProfileOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + intent, err := c.requireAppserviceIntent(req.UserID) + if err != nil { + return nil, err + } + if err := c.appservice.ensureRegistered(ctx, intent); err != nil { + return nil, err + } + if req.DisplayName != nil { + if err := retryMatrixVoid(ctx, func() error { + return intent.SetDisplayName(ctx, *req.DisplayName) + }); err != nil { + return nil, err + } + } + if req.AvatarURL != nil { + var avatarURL id.ContentURI + if *req.AvatarURL != "" { + parsedAvatarURL, err := id.ParseContentURI(*req.AvatarURL) + if err != nil { + return nil, err + } + avatarURL = parsedAvatarURL + } + if err := retryMatrixVoid(ctx, func() error { + return intent.SetAvatarURL(ctx, avatarURL) + }); err != nil { + return nil, err + } + } + if extra := appserviceProfileExtra(req); extra != nil { + if err := retryMatrixVoid(ctx, func() error { + return intent.BeeperUpdateProfile(ctx, extra) + }); err != nil { + return nil, err + } + } + return c.empty() +} + +func appserviceProfileExtra(req MatrixAppserviceSetProfileOptions) OutboundEvent { + extra := OutboundEvent{} + for key, value := range req.Extra { + extra[key] = value + } + baseExtra := event.BeeperProfileExtra{ + RemoteID: req.RemoteID, + Identifiers: req.Identifiers, + Service: req.Service, + Network: req.Network, + } + if req.IsNetworkBot != nil { + baseExtra.IsNetworkBot = *req.IsNetworkBot + } + if req.IsBridgeBot != nil { + baseExtra.IsBridgeBot = *req.IsBridgeBot + } + if baseExtra.RemoteID != "" || len(baseExtra.Identifiers) > 0 || baseExtra.Service != "" || baseExtra.Network != "" || baseExtra.IsNetworkBot || baseExtra.IsBridgeBot { + if payload, err := json.Marshal(baseExtra); err == nil { + _ = json.Unmarshal(payload, &extra) + } + } + if len(extra) == 0 { + return nil + } + return extra +} + func (c *Core) handleAppserviceCreateRoom(ctx context.Context, payload []byte) ([]byte, error) { var req MatrixAppserviceCreateRoomOptions if err := json.Unmarshal(payload, &req); err != nil { @@ -304,19 +450,22 @@ func (c *Core) handleAppserviceCreatePortalRoom(ctx context.Context, payload []b if err := json.Unmarshal(payload, &req); err != nil { return nil, err } - intent, err := c.requireAppserviceIntent(req.UserID) + resp, err := c.appserviceCreatePortalRoom(ctx, req) if err != nil { return nil, err } - if err := c.appservice.ensureRegistered(ctx, intent); err != nil { + return json.Marshal(MatrixCreateRoomResult{Raw: resp, RoomID: resp.RoomID.String()}) +} + +func (c *Core) appserviceCreatePortalRoom(ctx context.Context, req MatrixAppserviceCreatePortalRoomOptions) (*mautrix.RespCreateRoom, error) { + intent, err := c.requireAppserviceIntent(req.UserID) + if err != nil { return nil, err } - createReq := c.appservice.makePortalCreateRoomRequest(req, intent.UserID) - resp, err := intent.CreateRoom(ctx, createReq) - if err != nil { + if err := c.appservice.ensureRegistered(ctx, intent); err != nil { return nil, err } - return json.Marshal(MatrixCreateRoomResult{Raw: resp, RoomID: resp.RoomID.String()}) + return intent.CreateRoom(ctx, c.appservice.makePortalCreateRoomRequest(req, intent.UserID)) } func (c *Core) handleAppserviceCreateManagementRoom(ctx context.Context, payload []byte) ([]byte, error) { @@ -347,25 +496,20 @@ func (as *matrixAppservice) makePortalCreateRoomRequest(req MatrixAppserviceCrea } else if roomType == "" { roomType = "default" } - localRoomID := as.deterministicPortalRoomID(req.PortalKey) bridgeName := req.BridgeName if bridgeName == "" { bridgeName = req.Bridge.NetworkID } createReq := &mautrix.ReqCreateRoom{ - BeeperBridgeAccountID: req.PortalKey.Receiver, - BeeperBridgeName: bridgeName, - BeeperLocalRoomID: localRoomID, - CreationContent: map[string]any{}, - InitialState: make([]*event.Event, 0, 5), - Invite: toUserIDs(req.Invite), - IsDirect: req.IsDirect, - MeowRoomID: localRoomID, - Name: req.Name, - PowerLevelOverride: defaultBridgePowerLevels(bridgeBot), - Preset: "private_chat", - Topic: req.Topic, - Visibility: "private", + CreationContent: cloneMap(req.CreationContent), + InitialState: make([]*event.Event, 0, 5), + Invite: toUserIDs(req.Invite), + IsDirect: req.IsDirect, + Name: req.Name, + PowerLevelOverride: defaultBridgePowerLevels(bridgeBot), + Preset: "private_chat", + Topic: req.Topic, + Visibility: "private", } if req.AutoJoinInvites { createReq.BeeperAutoJoinInvites = true @@ -381,7 +525,7 @@ func (as *matrixAppservice) makePortalCreateRoomRequest(req MatrixAppserviceCrea } bridgeInfo := bridgeInfoContent(req, bridgeBot, roomType) for _, state := range req.InitialState { - stateKey := state.StateKey + stateKey := stringValue(state.StateKey) createReq.InitialState = append(createReq.InitialState, &event.Event{ Type: event.NewEventType(state.Type), StateKey: &stateKey, @@ -413,10 +557,6 @@ func (as *matrixAppservice) makeManagementCreateRoomRequest(req MatrixAppservice return createReq } -func (as *matrixAppservice) deterministicPortalRoomID(portalKey MatrixAppservicePortalKey) id.RoomID { - return id.RoomID(fmt.Sprintf("!%s.%s:%s", portalKey.ID, portalKey.Receiver, as.homeserverDomain)) -} - func defaultBridgePowerLevels(bridgeBot id.UserID) *event.PowerLevelsEventContent { return &event.PowerLevelsEventContent{ Events: map[string]int{ @@ -526,12 +666,24 @@ func (c *Core) handleAppserviceSendMessage(ctx context.Context, payload []byte) } func (c *Core) handleAppserviceBatchSend(ctx context.Context, payload []byte) ([]byte, error) { - as, err := c.requireAppservice() + var req MatrixAppserviceBatchSendOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + resp, err := c.appserviceBatchSend(ctx, req) if err != nil { return nil, err } - var req MatrixAppserviceBatchSendOptions - if err := json.Unmarshal(payload, &req); err != nil { + eventIDs := make([]string, 0, len(resp.EventIDs)) + for _, eventID := range resp.EventIDs { + eventIDs = append(eventIDs, eventID.String()) + } + return json.Marshal(MatrixAppserviceBatchSendResult{EventIDs: eventIDs, Raw: resp}) +} + +func (c *Core) appserviceBatchSend(ctx context.Context, req MatrixAppserviceBatchSendOptions) (*mautrix.RespBeeperBatchSend, error) { + as, err := c.requireAppservice() + if err != nil { return nil, err } events := make([]*event.Event, 0, len(req.Events)) @@ -554,21 +706,13 @@ func (c *Core) handleAppserviceBatchSend(ctx context.Context, payload []byte) ([ if err != nil { return nil, err } - resp, err := bot.BeeperBatchSend(ctx, id.RoomID(req.RoomID), &mautrix.ReqBeeperBatchSend{ + return bot.BeeperBatchSend(ctx, id.RoomID(req.RoomID), &mautrix.ReqBeeperBatchSend{ Events: events, Forward: req.Forward, ForwardIfNoMessages: req.ForwardIfNoMessages, MarkReadBy: id.UserID(req.MarkReadBy), SendNotification: req.SendNotification, }) - if err != nil { - return nil, err - } - eventIDs := make([]string, 0, len(resp.EventIDs)) - for _, eventID := range resp.EventIDs { - eventIDs = append(eventIDs, eventID.String()) - } - return json.Marshal(MatrixAppserviceBatchSendResult{EventIDs: eventIDs, Raw: resp}) } func (c *Core) requireAppservice() (*matrixAppservice, error) { @@ -602,7 +746,7 @@ func makeCreateRoomRequest(req MatrixCreateRoomOptions) *mautrix.ReqCreateRoom { invitees := toUserIDs(req.Invite) initialState := make([]*event.Event, 0, len(req.InitialState)) for _, state := range req.InitialState { - stateKey := state.StateKey + stateKey := stringValue(state.StateKey) initialState = append(initialState, &event.Event{ Type: event.NewEventType(state.Type), StateKey: &stateKey, @@ -705,3 +849,11 @@ func toUserIDs(input []string) []id.UserID { } return output } + +func cloneMap(input map[string]any) map[string]any { + output := make(map[string]any, len(input)) + for key, value := range input { + output[key] = value + } + return output +} diff --git a/packages/pickle/native/internal/core/appservice_test.go b/packages/pickle/native/internal/core/appservice_test.go index 4d3d13e..cb97432 100644 --- a/packages/pickle/native/internal/core/appservice_test.go +++ b/packages/pickle/native/internal/core/appservice_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + aistream "github.com/beeper/ai-bridge/pkg/ai-stream" "maunium.net/go/mautrix" "maunium.net/go/mautrix/beeperstream" "maunium.net/go/mautrix/event" @@ -28,7 +29,10 @@ func TestMakePortalCreateRoomRequestBuildsBridgeV2Room(t *testing.T) { DisplayName: "Test", NetworkID: "test", }, - BridgeName: "test", + BridgeName: "test", + CreationContent: map[string]any{ + "m.federate": false, + }, InitialMembers: []string{"@alice:example"}, Invite: []string{"@alice:example"}, Name: "Remote room", @@ -36,11 +40,14 @@ func TestMakePortalCreateRoomRequestBuildsBridgeV2Room(t *testing.T) { } createReq := appservice.makePortalCreateRoomRequest(req, id.UserID("@test_bob:example")) - if createReq.BeeperLocalRoomID != id.RoomID("!remote-room.login:a:example") { - t.Fatalf("unexpected local room ID: %s", createReq.BeeperLocalRoomID) + if createReq.BeeperLocalRoomID != "" { + t.Fatalf("expected homeserver-assigned room ID, got local room ID: %s", createReq.BeeperLocalRoomID) + } + if createReq.MeowRoomID != "" { + t.Fatalf("expected no fi.mau room ID override, got %s", createReq.MeowRoomID) } - if createReq.MeowRoomID != createReq.BeeperLocalRoomID { - t.Fatalf("expected fi.mau room ID to match local room ID, got %s", createReq.MeowRoomID) + if createReq.BeeperBridgeName != "" || createReq.BeeperBridgeAccountID != "" { + t.Fatalf("expected bridge details to stay in bridge state events for homeserver-assigned rooms, got name=%q account=%q", createReq.BeeperBridgeName, createReq.BeeperBridgeAccountID) } assertHasUserID(t, createReq.Invite, "@alice:example") assertHasUserID(t, createReq.BeeperInitialMembers, "@alice:example") @@ -50,6 +57,9 @@ func TestMakePortalCreateRoomRequestBuildsBridgeV2Room(t *testing.T) { if createReq.PowerLevelOverride.Events[event.StateBridge.Type] != 100 { t.Fatalf("expected m.bridge power level override, got %#v", createReq.PowerLevelOverride.Events) } + if createReq.CreationContent["m.federate"] != false { + t.Fatalf("expected portal creation content to preserve m.federate=false, got %#v", createReq.CreationContent) + } assertHasBridgeState(t, createReq, event.StateBridge.Type) assertHasBridgeState(t, createReq, event.StateHalfShotBridge.Type) } @@ -96,6 +106,123 @@ func TestAppserviceTransactionParsesBeeperStreamSubscribe(t *testing.T) { } } +func TestAppserviceTransactionEmitsMautrixClassifiedEvents(t *testing.T) { + var emitted []OutboundEvent + core := New(func(evt OutboundEvent) { + emitted = append(emitted, evt) + }) + core.appserviceProcessor = newBeeperStreamEventProcessor() + + rawTxn := map[string]any{ + "events": []any{ + map[string]any{ + "content": map[string]any{"name": "Project room"}, + "event_id": "$name", + "room_id": "!room:example", + "sender": "@alice:example", + "state_key": "", + "type": "m.room.name", + }, + map[string]any{ + "content": map[string]any{"membership": "invite"}, + "event_id": "$member", + "room_id": "!room:example", + "sender": "@alice:example", + "state_key": "@bob:example", + "type": "m.room.member", + }, + }, + "ephemeral": []any{ + map[string]any{ + "content": map[string]any{ + "$message": map[string]any{ + "m.read": map[string]any{ + "@alice:example": map[string]any{"ts": 1}, + }, + }, + }, + "room_id": "!room:example", + "type": "m.receipt", + }, + }, + "room_account_data": []any{ + map[string]any{ + "content": map[string]any{"unread": true}, + "room_id": "!room:example", + "type": "m.marked_unread", + }, + }, + } + payload, err := json.Marshal(MatrixAppserviceTransactionOptions{Transaction: mustJSON(t, rawTxn)}) + if err != nil { + t.Fatal(err) + } + if _, err := core.handleAppserviceApplyTransaction(context.Background(), payload); err != nil { + t.Fatal(err) + } + + assertEmittedSyncEvent(t, emitted, "room_state", "m.room.name", "!room:example") + assertEmittedSyncEvent(t, emitted, "membership", "m.room.member", "!room:example") + assertEmittedSyncEvent(t, emitted, "receipt", "m.receipt", "!room:example") + assertEmittedSyncEvent(t, emitted, "account_data", "m.marked_unread", "!room:example") +} + +func TestAppserviceSetProfileUpdatesGhostProfile(t *testing.T) { + requests := make(chan recordedRequest, 8) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- recordedRequest{body: string(body), path: r.Method + " " + r.URL.RequestURI()} + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(server.Close) + + core := New(nil) + initPayload, err := json.Marshal(MatrixAppserviceInitOptions{ + Homeserver: server.URL, + HomeserverDomain: "example", + Registration: MatrixAppserviceRegistration{ + AppToken: "as-token", + ID: "test", + SenderLocalpart: "testbot", + }, + }) + if err != nil { + t.Fatal(err) + } + if _, err = core.handleInitAppservice(context.Background(), initPayload); err != nil { + t.Fatal(err) + } + + payload, err := json.Marshal(MatrixAppserviceSetProfileOptions{ + AvatarURL: ptr("mxc://example/avatar"), + DisplayName: ptr("Agent Main"), + Identifiers: []string{"openclaw:agent:main"}, + IsBridgeBot: ptr(false), + IsNetworkBot: ptr(true), + Network: "openclaw", + RemoteID: "agent_main", + Service: "openclaw", + UserID: "@test_agent_main:example", + }) + if err != nil { + t.Fatal(err) + } + if _, err = core.handleAppserviceSetProfile(context.Background(), payload); err != nil { + t.Fatal(err) + } + + expectRecordedRequest(t, requests, "POST", "/register", `"username":"test_agent_main"`) + expectRecordedRequest(t, requests, "PUT", "/displayname", `"displayname":"Agent Main"`) + expectRecordedRequest(t, requests, "PUT", "/avatar_url", `"avatar_url":"mxc://example/avatar"`) + waitForRecordedRequest(t, requests, func(req recordedRequest) bool { + return strings.HasPrefix(req.path, "PATCH ") && + strings.Contains(req.path, "/profile/") && + strings.Contains(req.body, `"com.beeper.bridge.remote_id":"agent_main"`) && + strings.Contains(req.body, `"com.beeper.bridge.identifiers":["openclaw:agent:main"]`) + }) +} + func TestBeeperStreamClientUsesAppserviceBotDevice(t *testing.T) { core := New(nil) mainClient, err := mautrix.NewClient("https://matrix.example/_hungryserv/alice", id.UserID("@bot:example"), "login-token") @@ -201,6 +328,326 @@ func TestCreateBeeperStreamUsesMautrixEncryptionDecision(t *testing.T) { } } +func TestBeeperStreamCarrierContentUsesAIBridgeEnvelopeShape(t *testing.T) { + core := New(nil) + + content, err := core.beeperStreamCarrierContent("com.beeper.llm", MatrixPublishBeeperStreamMessagePartOptions{ + AgentID: "codex", + EventID: "$stream", + Part: OutboundEvent{ + "delta": "hello", + "messageId": "msg-1", + "model": "openclaw/codex", + "runId": "run-1", + "threadId": "thread-1", + "type": "TEXT_MESSAGE_CONTENT", + }, + TurnID: "run-1", + }, 7) + if err != nil { + t.Fatal(err) + } + payload, ok := content[aistream.BeeperAIKey].(aistream.BeeperAI) + if !ok || len(payload.Events) != 1 { + t.Fatalf("expected ai-bridge stream payload, got %#v", content) + } + envelope := payload.Events[0] + if envelope.Seq != 7 || payload.Agent.ID != "codex" { + t.Fatalf("unexpected ai-bridge envelope routing fields: payload=%#v envelope=%#v", payload, envelope) + } + if payload.ThreadID != "thread-1" || payload.RunID != "run-1" || payload.MessageID != "msg-1" { + t.Fatalf("unexpected ai-bridge run identity: %#v", payload) + } + if envelope.Event.Type() != "TEXT_MESSAGE_CONTENT" || envelope.Event.Get("delta") != "hello" { + t.Fatalf("unexpected ai-bridge event payload: %#v", envelope.Event.Map()) + } + if !envelope.Event.Has("timestamp") { + t.Fatalf("expected native bridge to add timestamp before ai-bridge validation: %#v", envelope.Event.Map()) + } + + custom, err := core.beeperStreamCarrierContent("com.example.custom", MatrixPublishBeeperStreamMessagePartOptions{ + EventID: "$stream", + Part: OutboundEvent{ + "delta": "custom", + "messageId": "turn-1", + "type": "TEXT_MESSAGE_CONTENT", + }, + TurnID: "turn-1", + }, 1) + if err != nil { + t.Fatal(err) + } + if _, ok := custom[aistream.BeeperAIKey].(aistream.BeeperAI); !ok { + t.Fatalf("expected custom stream type to use ai-bridge payload, got %#v", custom) + } +} + +func TestBeeperStreamPublishWithoutSubscribersSendsRoomCarrierEvent(t *testing.T) { + requests := make(chan recordedRequest, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- recordedRequest{body: string(body), path: r.URL.Path} + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"event_id":"$event"}`)) + })) + t.Cleanup(server.Close) + + core := New(nil) + cli, err := mautrix.NewClient(server.URL, id.UserID("@testbot:example"), "device-token") + if err != nil { + t.Fatal(err) + } + cli.DeviceID = id.DeviceID("PICKLE") + cli.StateStore = mautrix.NewMemoryStateStore() + core.client = cli + core.beeperStream, err = beeperstream.New(cli) + if err != nil { + t.Fatal(err) + } + + startReq, err := json.Marshal(MatrixStartBeeperStreamMessageOptions{ + RoomID: "!room:example", + StreamType: "com.beeper.llm", + }) + if err != nil { + t.Fatal(err) + } + if _, err = core.handleStartBeeperStreamMessage(context.Background(), startReq); err != nil { + t.Fatal(err) + } + + select { + case req := <-requests: + if !strings.Contains(req.body, `"com.beeper.stream":{"type":"com.beeper.llm"}`) { + t.Fatalf("expected room-carrier anchor descriptor, got %s", req.body) + } + default: + t.Fatal("expected stream anchor request") + } + + publishReq, err := json.Marshal(MatrixPublishBeeperStreamMessagePartOptions{ + EventID: "$event", + Part: OutboundEvent{ + "delta": "hello", + "messageId": "turn-test", + "type": "TEXT_MESSAGE_CONTENT", + }, + RoomID: "!room:example", + TurnID: "turn-test", + }) + if err != nil { + t.Fatal(err) + } + if _, err = core.handlePublishBeeperStreamMessagePart(context.Background(), publishReq); err != nil { + t.Fatal(err) + } + + deadline := time.After(time.Second) + for { + select { + case req := <-requests: + if !strings.Contains(req.path, "/rooms/!room:example/send/m.room.message/") { + continue + } + if !strings.Contains(req.body, `"com.beeper.ai"`) { + continue + } + if !strings.Contains(req.body, `"body":""`) || !strings.Contains(req.body, `"msgtype":"m.text"`) { + t.Fatalf("expected hidden m.text carrier event, got %s", req.body) + } + if !strings.Contains(req.body, `"rel_type":"m.reference"`) || !strings.Contains(req.body, `"event_id":"$event"`) { + t.Fatalf("expected carrier event to reference stream root, got %s", req.body) + } + if !strings.Contains(req.body, `"delta":"hello"`) { + t.Fatalf("expected ai-bridge stream payload in carrier body, got %s", req.body) + } + return + case <-deadline: + t.Fatal("timed out waiting for room carrier stream event") + } + } +} + +func TestBeeperAIRunStreamUsesCanonicalAIBridgeRun(t *testing.T) { + requests := make(chan recordedRequest, 16) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- recordedRequest{body: string(body), path: r.URL.Path} + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"event_id":"$event"}`)) + })) + t.Cleanup(server.Close) + + core := New(nil) + cli, err := mautrix.NewClient(server.URL, id.UserID("@testbot:example"), "device-token") + if err != nil { + t.Fatal(err) + } + cli.DeviceID = id.DeviceID("PICKLE") + cli.StateStore = mautrix.NewMemoryStateStore() + core.client = cli + core.beeperStream, err = beeperstream.New(cli) + if err != nil { + t.Fatal(err) + } + + startReq, err := json.Marshal(MatrixStartBeeperAIRunStreamOptions{ + MatrixBeginBeeperAIRunOptions: MatrixBeginBeeperAIRunOptions{ + AgentID: "codex", + AgentName: "Codex", + Data: OutboundEvent{"session_key": "session-1"}, + Model: "openclaw/plugin", + RunID: "run-1", + ThreadID: "thread-1", + }, + RoomID: "!room:example", + }) + if err != nil { + t.Fatal(err) + } + rawStart, err := core.handleStartBeeperAIRunStream(context.Background(), startReq) + if err != nil { + t.Fatal(err) + } + var startResult MatrixBeeperAIRunStreamResult + if err = json.Unmarshal(rawStart, &startResult); err != nil { + t.Fatal(err) + } + if startResult.EventID != "$event" || startResult.MessageID != "msg-run-1" { + t.Fatalf("unexpected start result: %#v", startResult) + } + anchorBody := waitForRecordedRequest(t, requests, func(req recordedRequest) bool { + return strings.Contains(req.body, `"com.beeper.ai"`) && strings.Contains(req.body, `"com.beeper.stream"`) + }) + if strings.Contains(anchorBody, "Working...") || !strings.Contains(anchorBody, `"body":""`) { + t.Fatalf("empty stream anchor should not expose working fallback, got %s", anchorBody) + } + + appendReq, err := json.Marshal(MatrixAppendBeeperAIRunEventOptions{ + Event: OutboundEvent{ + "delta": "hello", + "messageId": "provider-msg", + "type": "TEXT_MESSAGE_CONTENT", + }, + RunID: "run-1", + }) + if err != nil { + t.Fatal(err) + } + if _, err = core.handleAppendBeeperAIRunStreamEvent(context.Background(), appendReq); err != nil { + t.Fatal(err) + } + carrierBody := waitForRecordedRequest(t, requests, func(req recordedRequest) bool { + return strings.Contains(req.body, `"delta":"hello"`) + }) + if !strings.Contains(carrierBody, `"messageId":"msg-run-1"`) { + t.Fatalf("expected canonical envelope message id, got %s", carrierBody) + } + if strings.Contains(carrierBody, `"messageId":"provider-msg"`) { + t.Fatalf("expected provider message id to be canonicalized by native stream, got %s", carrierBody) + } + + finishReq, err := json.Marshal(MatrixFinishBeeperAIRunOptions{ + FinishReason: "stop", + RunID: "run-1", + }) + if err != nil { + t.Fatal(err) + } + rawFinish, err := core.handleFinishBeeperAIRunStream(context.Background(), finishReq) + if err != nil { + t.Fatal(err) + } + var finishResult MatrixBeeperAIRunStreamResult + if err = json.Unmarshal(rawFinish, &finishResult); err != nil { + t.Fatal(err) + } + if finishResult.ReplacementEventID == "" || finishResult.Body != "hello" { + t.Fatalf("unexpected finish result: %#v", finishResult) + } + if _, ok := core.beeperAIRuns["run-1"]; ok { + t.Fatal("expected finalized stream run to be deleted") + } + replacementBody := waitForRecordedRequest(t, requests, func(req recordedRequest) bool { + return strings.Contains(req.body, `"m.new_content"`) + }) + if !strings.Contains(replacementBody, `"com.beeper.ai"`) || !strings.Contains(replacementBody, `"hello"`) { + t.Fatalf("expected final replacement to use ai-bridge final content, got %s", replacementBody) + } + var replacementContent map[string]any + if err = json.Unmarshal([]byte(replacementBody), &replacementContent); err != nil { + t.Fatal(err) + } + if replacementContent["body"] != "hello" { + t.Fatalf("expected final replacement top-level body to preserve rendered text, got %#v", replacementContent["body"]) + } +} + +func TestBeeperAIRunStreamStartUsesInitialTextPartForAnchorPreview(t *testing.T) { + requests := make(chan recordedRequest, 16) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- recordedRequest{body: string(body), path: r.URL.Path} + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"event_id":"$event"}`)) + })) + t.Cleanup(server.Close) + + core := New(nil) + cli, err := mautrix.NewClient(server.URL, id.UserID("@testbot:example"), "device-token") + if err != nil { + t.Fatal(err) + } + cli.DeviceID = id.DeviceID("PICKLE") + cli.StateStore = mautrix.NewMemoryStateStore() + core.client = cli + core.beeperStream, err = beeperstream.New(cli) + if err != nil { + t.Fatal(err) + } + + startReq, err := json.Marshal(MatrixStartBeeperAIRunStreamOptions{ + MatrixBeginBeeperAIRunOptions: MatrixBeginBeeperAIRunOptions{ + AgentID: "codex", + AgentName: "Codex", + Model: "openclaw/plugin", + RunID: "run-preview", + ThreadID: "thread-preview", + }, + InitialParts: []MatrixBeeperAIRunPartOptions{{ + Kind: "text", + Text: "hello", + }}, + RoomID: "!room:example", + }) + if err != nil { + t.Fatal(err) + } + rawStart, err := core.handleStartBeeperAIRunStream(context.Background(), startReq) + if err != nil { + t.Fatal(err) + } + var startResult MatrixBeeperAIRunStreamResult + if err = json.Unmarshal(rawStart, &startResult); err != nil { + t.Fatal(err) + } + if startResult.Body != "hello" { + t.Fatalf("expected start snapshot body to use initial text, got %#v", startResult) + } + anchorBody := waitForRecordedRequest(t, requests, func(req recordedRequest) bool { + return strings.Contains(req.body, `"com.beeper.ai"`) && strings.Contains(req.body, `"com.beeper.stream"`) + }) + if !strings.Contains(anchorBody, `"body":"hello"`) || strings.Contains(anchorBody, "Working...") { + t.Fatalf("expected anchor preview to use initial text, got %s", anchorBody) + } + carrierBody := waitForRecordedRequest(t, requests, func(req recordedRequest) bool { + return strings.Contains(req.body, `"TEXT_MESSAGE_CONTENT"`) && strings.Contains(req.body, `"delta":"hello"`) + }) + if !strings.Contains(carrierBody, `"seq":`) { + t.Fatalf("expected initial text part to be published as a stream carrier, got %s", carrierBody) + } +} + func TestRegisterBeeperStreamInjectsDirectSubscribers(t *testing.T) { requests := make(chan recordedRequest, 4) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -286,6 +733,34 @@ type recordedRequest struct { path string } +func waitForRecordedRequest(t *testing.T, requests <-chan recordedRequest, matches func(recordedRequest) bool) string { + t.Helper() + deadline := time.After(time.Second) + for { + select { + case req := <-requests: + if matches(req) { + return req.body + } + case <-deadline: + t.Fatal("timed out waiting for recorded request") + } + } +} + +func expectRecordedRequest(t *testing.T, requests <-chan recordedRequest, method string, pathFragment string, bodyFragment string) { + t.Helper() + waitForRecordedRequest(t, requests, func(req recordedRequest) bool { + return strings.HasPrefix(req.path, method+" ") && + strings.Contains(req.path, pathFragment) && + strings.Contains(req.body, bodyFragment) + }) +} + +func ptr[T any](value T) *T { + return &value +} + func mustJSON(t *testing.T, value any) json.RawMessage { t.Helper() raw, err := json.Marshal(value) @@ -324,3 +799,20 @@ func assertHasBridgeState(t *testing.T, req *mautrix.ReqCreateRoom, eventType st } t.Fatalf("missing %s initial state", eventType) } + +func assertEmittedSyncEvent(t *testing.T, events []OutboundEvent, eventType string, matrixType string, roomID string) { + t.Helper() + for _, outbound := range events { + if outbound["type"] != eventType { + continue + } + rawEvent, ok := outbound["event"].(MatrixSyncEvent) + if !ok { + t.Fatalf("expected MatrixSyncEvent for %s, got %#v", eventType, outbound["event"]) + } + if rawEvent.Type == matrixType && stringValue(rawEvent.RoomID) == roomID { + return + } + } + t.Fatalf("missing emitted %s event for %s in %v", eventType, matrixType, events) +} diff --git a/packages/pickle/native/internal/core/beeper_ai_run.go b/packages/pickle/native/internal/core/beeper_ai_run.go new file mode 100644 index 0000000..5646f58 --- /dev/null +++ b/packages/pickle/native/internal/core/beeper_ai_run.go @@ -0,0 +1,1066 @@ +package core + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + agui "github.com/beeper/ai-bridge/pkg/ag-ui" + aistream "github.com/beeper/ai-bridge/pkg/ai-stream" + aimatrix "github.com/beeper/ai-bridge/pkg/ai-stream/matrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type beeperAIRunState struct { + published int + run *aistream.Run + endedToolInputs map[string]bool + streamDescriptor any + streamEventID id.EventID + streamRoomID id.RoomID + startedToolCalls map[string]bool + toolInputs map[string]any + toolNames map[string]string + writer *aistream.Writer +} + +const beeperAIRunStreamOperationTimeout = 30 * time.Second + +type MatrixBeginBeeperAIRunOptions struct { + AgentID string `json:"agentId,omitempty"` + AgentName string `json:"agentName,omitempty"` + Data OutboundEvent `json:"data,omitempty" tstype:"{ [key: string]: unknown }"` + MessageID string `json:"messageId,omitempty"` + Model string `json:"model,omitempty"` + RunID string `json:"runId,omitempty"` + ThreadID string `json:"threadId,omitempty"` +} + +type MatrixAppendBeeperAIRunEventOptions struct { + Event OutboundEvent `json:"event" tstype:"{ [key: string]: unknown }"` + RunID string `json:"runId"` +} + +type MatrixBeeperAIRunPartOptions struct { + ActivityType string `json:"activityType,omitempty"` + Aggregated string `json:"aggregated,omitempty"` + Approval any `json:"approval,omitempty" tstype:"unknown"` + Command string `json:"command,omitempty"` + CompletedAtMs *int64 `json:"completedAtMs,omitempty"` + Content OutboundEvent `json:"content,omitempty" tstype:"{ [key: string]: unknown }"` + Cwd string `json:"cwd,omitempty"` + Description string `json:"description,omitempty"` + Delta any `json:"delta,omitempty" tstype:"unknown"` + Details any `json:"details,omitempty" tstype:"unknown"` + Dynamic *bool `json:"dynamic,omitempty"` + Error any `json:"error,omitempty" tstype:"unknown"` + ExitCode *int `json:"exitCode,omitempty"` + Index *int `json:"index,omitempty"` + Input any `json:"input,omitempty" tstype:"unknown"` + Kind string `json:"kind" tstype:"\"text\" | \"reasoning\" | \"reasoning_end\" | \"tool_start\" | \"tool_input\" | \"tool_end\" | \"tool_result\" | \"activity\" | \"activity_delta\" | \"state_delta\" | \"state_snapshot\" | \"raw\" | \"custom\" | string"` + Metadata OutboundEvent `json:"metadata,omitempty" tstype:"{ [key: string]: unknown }"` + Name string `json:"name,omitempty"` + Output any `json:"output,omitempty" tstype:"unknown"` + Patch any `json:"patch,omitempty" tstype:"unknown"` + Preliminary bool `json:"preliminary,omitempty"` + ProviderExecuted *bool `json:"providerExecuted,omitempty"` + Replace *bool `json:"replace,omitempty"` + Response any `json:"response,omitempty" tstype:"unknown"` + Result any `json:"result,omitempty" tstype:"unknown"` + Source string `json:"source,omitempty"` + State string `json:"state,omitempty"` + Status string `json:"status,omitempty"` + StartedAtMs *int64 `json:"startedAtMs,omitempty"` + Stderr string `json:"stderr,omitempty"` + Stdout string `json:"stdout,omitempty"` + Text string `json:"text,omitempty"` + Title string `json:"title,omitempty"` + ToolCallID string `json:"toolCallId,omitempty"` + ToolName string `json:"toolName,omitempty"` + Value any `json:"value,omitempty" tstype:"unknown"` +} + +type MatrixAppendBeeperAIRunPartOptions struct { + MatrixBeeperAIRunPartOptions `json:",inline" tstype:",extends"` + RunID string `json:"runId"` +} + +type MatrixFinishBeeperAIRunOptions struct { + FinishReason string `json:"finishReason,omitempty"` + RunID string `json:"runId"` + Terminal OutboundEvent `json:"terminal,omitempty" tstype:"{ [key: string]: unknown }"` + Usage agui.Usage `json:"usage,omitempty"` +} + +type MatrixErrorBeeperAIRunOptions struct { + Message string `json:"message,omitempty"` + RunID string `json:"runId"` + Terminal OutboundEvent `json:"terminal,omitempty" tstype:"{ [key: string]: unknown }"` + Type string `json:"type,omitempty" tstype:"\"error\" | \"abort\""` +} + +type MatrixDeleteBeeperAIRunOptions struct { + RunID string `json:"runId"` +} + +type MatrixBeeperAIRunSnapshot struct { + Body string `json:"body"` + Events []OutboundEvent `json:"events" tstype:"Array<{ [key: string]: unknown }>"` + InitialAIMessage any `json:"initialAIMessage" tstype:"{ [key: string]: unknown }"` + FinalAIMessage any `json:"finalAIMessage" tstype:"{ [key: string]: unknown }"` + Metadata any `json:"metadata" tstype:"{ [key: string]: unknown }"` + MessageID string `json:"messageId"` + RunID string `json:"runId"` + ThreadID string `json:"threadId"` +} + +type MatrixStartBeeperAIRunStreamOptions struct { + MatrixBeginBeeperAIRunOptions `json:",inline" tstype:",extends"` + InitialEvents []OutboundEvent `json:"initialEvents,omitempty" tstype:"Array<{ [key: string]: unknown }>"` + InitialParts []MatrixBeeperAIRunPartOptions `json:"initialParts,omitempty"` + RoomID string `json:"roomId"` + StreamType string `json:"streamType,omitempty"` + Subscribers []MatrixBeeperStreamSubscriber `json:"subscribers,omitempty"` + ThreadRootEventID string `json:"threadRootEventId,omitempty"` + UserID string `json:"userId,omitempty"` +} + +type MatrixBeeperAIRunStreamResult struct { + MatrixBeeperAIRunSnapshot `json:",inline" tstype:",extends"` + Descriptor any `json:"descriptor,omitempty" tstype:"{ [key: string]: unknown }"` + EventID string `json:"eventId"` + Raw any `json:"raw,omitempty"` + ReplacementEventID string `json:"replacementEventId,omitempty"` + RoomID string `json:"roomId"` +} + +func (c *Core) handleBeginBeeperAIRun(payload []byte) ([]byte, error) { + var req MatrixBeginBeeperAIRunOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + state, err := c.beginBeeperAIRun(req) + if err != nil { + return nil, err + } + run := state.run + return c.marshalBeeperAIRunSnapshot(run, outboundEventsFromAGUI(run.Events)) +} + +func (c *Core) handleAppendBeeperAIRunEvent(payload []byte) ([]byte, error) { + var req MatrixAppendBeeperAIRunEventOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + state, err := c.requireBeeperAIRun(req.RunID) + if err != nil { + return nil, err + } + before := len(state.run.Events) + if err := state.appendEvent(req.Event); err != nil { + return nil, err + } + return c.marshalBeeperAIRunSnapshot(state.run, outboundEventsFromAGUI(state.run.Events[before:])) +} + +func (c *Core) handleFinishBeeperAIRun(payload []byte) ([]byte, error) { + var req MatrixFinishBeeperAIRunOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + state, err := c.requireBeeperAIRun(req.RunID) + if err != nil { + return nil, err + } + before := len(state.run.Events) + if req.Usage.PromptTokens != 0 || req.Usage.CompletionTokens != 0 || req.Usage.ReasoningTokens != 0 || req.Usage.TotalTokens != 0 { + usage := req.Usage + state.writer.FinishWithUsage(req.FinishReason, &usage) + } else { + state.writer.Finish(req.FinishReason) + } + return c.marshalBeeperAIRunSnapshot(state.run, outboundEventsFromAGUI(state.run.Events[before:])) +} + +func (c *Core) handleErrorBeeperAIRun(payload []byte) ([]byte, error) { + var req MatrixErrorBeeperAIRunOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + state, err := c.requireBeeperAIRun(req.RunID) + if err != nil { + return nil, err + } + before := len(state.run.Events) + message := strings.TrimSpace(req.Message) + if message == "" { + message = "run failed" + } + if req.Type == "abort" { + state.writer.Abort(message) + } else { + state.writer.Error(message) + } + if state.run.Preview.Text == "" { + state.run.Preview = aistream.PreviewFromText(message, aistream.PreviewBudgetBytes) + } + return c.marshalBeeperAIRunSnapshot(state.run, outboundEventsFromAGUI(state.run.Events[before:])) +} + +func (c *Core) handleDeleteBeeperAIRun(payload []byte) ([]byte, error) { + var req MatrixDeleteBeeperAIRunOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + delete(c.beeperAIRuns, req.RunID) + return c.empty() +} + +func (c *Core) handleStartBeeperAIRunStream(ctx context.Context, payload []byte) ([]byte, error) { + ctx, cancel := beeperAIRunStreamContext(ctx) + defer cancel() + if c.beeperStream == nil { + return nil, errors.New("beeper stream helper is not initialized") + } + var req MatrixStartBeeperAIRunStreamOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + if req.RoomID == "" { + return nil, errors.New("missing beeper AI run stream room ID") + } + if req.StreamType == "" { + req.StreamType = "com.beeper.llm" + } + state, err := c.beginBeeperAIRun(req.MatrixBeginBeeperAIRunOptions) + if err != nil { + return nil, err + } + run := state.run + for _, eventData := range req.InitialEvents { + if err := state.appendEvent(eventData); err != nil { + delete(c.beeperAIRuns, run.RunID) + return nil, err + } + } + for _, part := range req.InitialParts { + if err := state.appendPart(part); err != nil { + delete(c.beeperAIRuns, run.RunID) + return nil, err + } + } + descriptor, err := c.beeperStream.NewDescriptor(ctx, id.RoomID(req.RoomID), req.StreamType) + if err != nil { + return nil, err + } + content, extra := aimatrix.AnchorContent(*run) + clearBeeperAIWorkingFallback(content, *run) + contentMap := messageContentMap(content, OutboundEvent(extra)) + if len(req.Subscribers) > 0 { + contentMap["com.beeper.stream"] = descriptor + } else { + contentMap["com.beeper.stream"] = map[string]any{ + "type": req.StreamType, + } + } + resp, err := c.sendBeeperStreamMessageEvent(ctx, req.RoomID, req.ThreadRootEventID, req.UserID, contentMap) + if err != nil { + delete(c.beeperAIRuns, run.RunID) + return nil, err + } + eventID := id.EventID(resp.EventID.String()) + if err = c.beeperStream.Register(ctx, id.RoomID(req.RoomID), eventID, descriptor); err != nil { + delete(c.beeperAIRuns, run.RunID) + return nil, err + } + c.beeperStreamMessages[eventID] = &beeperStreamMessage{ + descriptor: descriptor.Clone(), + direct: len(req.Subscribers) > 0, + nextSeq: 1, + roomID: id.RoomID(req.RoomID), + userID: req.UserID, + } + state.streamEventID = eventID + state.streamRoomID = id.RoomID(req.RoomID) + state.streamDescriptor = descriptor.Clone() + c.addBeeperStreamSubscribers(ctx, id.RoomID(req.RoomID), eventID, req.Subscribers) + events := outboundEventsFromAGUI(run.Events) + if err = c.publishBeeperAIRunStreamPending(ctx, state); err != nil { + delete(c.beeperAIRuns, run.RunID) + delete(c.beeperStreamMessages, eventID) + c.beeperStream.Unregister(id.RoomID(req.RoomID), eventID) + c.beeperStream.Unsubscribe(id.RoomID(req.RoomID), eventID) + return nil, err + } + return c.marshalBeeperAIRunStreamResult(state, events, "", nil) +} + +func (c *Core) handleAppendBeeperAIRunStreamEvent(ctx context.Context, payload []byte) ([]byte, error) { + ctx, cancel := beeperAIRunStreamContext(ctx) + defer cancel() + var req MatrixAppendBeeperAIRunEventOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + state, err := c.requireBeeperAIRun(req.RunID) + if err != nil { + return nil, err + } + before := len(state.run.Events) + if err := state.appendEvent(req.Event); err != nil { + return nil, err + } + events := outboundEventsFromAGUI(state.run.Events[before:]) + if err := c.publishBeeperAIRunStreamPending(ctx, state); err != nil { + return nil, err + } + return c.marshalBeeperAIRunStreamResult(state, events, "", nil) +} + +func (c *Core) handleAppendBeeperAIRunStreamPart(ctx context.Context, payload []byte) ([]byte, error) { + ctx, cancel := beeperAIRunStreamContext(ctx) + defer cancel() + var req MatrixAppendBeeperAIRunPartOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + state, err := c.requireBeeperAIRun(req.RunID) + if err != nil { + return nil, err + } + before := len(state.run.Events) + if err := state.appendPart(req.MatrixBeeperAIRunPartOptions); err != nil { + return nil, err + } + events := outboundEventsFromAGUI(state.run.Events[before:]) + if err := c.publishBeeperAIRunStreamPending(ctx, state); err != nil { + return nil, err + } + return c.marshalBeeperAIRunStreamResult(state, events, "", nil) +} + +func (c *Core) handleFinishBeeperAIRunStream(ctx context.Context, payload []byte) ([]byte, error) { + ctx, cancel := beeperAIRunStreamContext(ctx) + defer cancel() + var req MatrixFinishBeeperAIRunOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + state, err := c.requireBeeperAIRun(req.RunID) + if err != nil { + return nil, err + } + before := len(state.run.Events) + if req.Usage.PromptTokens != 0 || req.Usage.CompletionTokens != 0 || req.Usage.ReasoningTokens != 0 || req.Usage.TotalTokens != 0 { + usage := req.Usage + state.writer.FinishWithUsage(req.FinishReason, &usage) + } else { + state.writer.Finish(req.FinishReason) + } + events := outboundEventsFromAGUI(state.run.Events[before:]) + if err := c.publishBeeperAIRunStreamPending(ctx, state); err != nil { + return nil, err + } + return c.finalizeBeeperAIRunStream(ctx, state, events) +} + +func (c *Core) handleErrorBeeperAIRunStream(ctx context.Context, payload []byte) ([]byte, error) { + ctx, cancel := beeperAIRunStreamContext(ctx) + defer cancel() + var req MatrixErrorBeeperAIRunOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + state, err := c.requireBeeperAIRun(req.RunID) + if err != nil { + return nil, err + } + before := len(state.run.Events) + message := strings.TrimSpace(req.Message) + if message == "" { + message = "run failed" + } + if req.Type == "abort" { + state.writer.Abort(message) + } else { + state.writer.Error(message) + } + if state.run.Preview.Text == "" { + state.run.Preview = aistream.PreviewFromText(message, aistream.PreviewBudgetBytes) + } + events := outboundEventsFromAGUI(state.run.Events[before:]) + if err := c.publishBeeperAIRunStreamPending(ctx, state); err != nil { + return nil, err + } + return c.finalizeBeeperAIRunStream(ctx, state, events) +} + +func (c *Core) beginBeeperAIRun(req MatrixBeginBeeperAIRunOptions) (*beeperAIRunState, error) { + runID := strings.TrimSpace(req.RunID) + if runID == "" { + return nil, errors.New("missing Beeper AI run ID") + } + if c.beeperAIRuns[runID] != nil { + return nil, errors.New("Beeper AI run is already registered") + } + run := aistream.NewRun(runID, req.ThreadID, req.Model, req.AgentID, req.AgentName, time.Now()) + if strings.TrimSpace(req.MessageID) != "" { + run.MessageID = strings.TrimSpace(req.MessageID) + } + if len(req.Data) > 0 { + run.Data = map[string]any(req.Data) + } + writer := aistream.NewWriter(run, time.Now) + writer.Start() + state := &beeperAIRunState{ + endedToolInputs: map[string]bool{}, + run: run, + startedToolCalls: map[string]bool{}, + toolInputs: map[string]any{}, + toolNames: map[string]string{}, + writer: writer, + } + c.beeperAIRuns[run.RunID] = state + return state, nil +} + +func (s *beeperAIRunState) appendEvent(eventData OutboundEvent) error { + event := agui.NewEvent(map[string]any(copyOutboundEvent(eventData))) + if !event.Has("timestamp") { + event.Set("timestamp", time.Now().UnixMilli()) + } + canonicalizeBeeperAIRunEvent(s.run, event) + if err := agui.ValidateEvent(event); err != nil { + return err + } + s.writer.Add(event) + return nil +} + +func (s *beeperAIRunState) appendPart(req MatrixBeeperAIRunPartOptions) error { + if s == nil || s.writer == nil { + return errors.New("Beeper AI run is not initialized") + } + kind := normalizeBeeperAIRunPartKind(req.Kind) + switch kind { + case "text", "text_delta": + s.writer.Text(firstNonEmpty(req.Text, stringFromAny(req.Delta), stringFromAny(req.Value))) + case "text_end": + s.writer.TextEnd(beeperAIRunPartIndex(req)) + case "reasoning", "reasoning_delta", "thinking": + s.writer.ReasoningDelta(beeperAIRunPartIndex(req), firstNonEmpty(req.Text, stringFromAny(req.Delta), stringFromAny(req.Value))) + case "reasoning_end", "thinking_end": + s.writer.ReasoningMessageEnd(beeperAIRunPartIndex(req)) + case "tool_start": + s.ensureToolStarted(req) + case "tool_input", "tool_input_delta": + toolCallID, toolName := s.ensureToolStarted(req) + input := firstNonNil(req.Input, req.Value) + if input != nil { + s.toolInputs[toolCallID] = input + } + delta := firstNonEmpty(req.Text, stringFromAny(req.Delta), beeperAIJSONString(input)) + s.writer.ToolArgs(toolCallID, delta, input) + if toolName != "" { + s.toolNames[toolCallID] = toolName + } + case "tool_end", "tool_input_complete": + toolCallID, toolName := s.ensureToolStarted(req) + s.endToolInput(toolCallID, toolName, firstNonNil(req.Input, s.toolInputs[toolCallID])) + case "tool_result": + toolCallID, toolName := s.ensureToolStarted(req) + input := firstNonNil(req.Input, s.toolInputs[toolCallID]) + if !req.Preliminary { + s.endToolInput(toolCallID, toolName, input) + } + state := req.State + if state == "" { + if req.Error != nil { + state = agui.ToolResultStateError + } else if req.Preliminary { + state = agui.ToolResultStateStreaming + } else { + state = agui.ToolResultStateComplete + } + } + result := firstNonNil(req.Error, req.Output, req.Value) + if isCommandToolName(toolName) { + result = commandToolOutput(req) + } + content := firstNonEmpty(req.Text, beeperAIJSONString(result)) + if content != "" || !req.Preliminary { + before := len(s.run.Events) + s.writer.ToolResult(toolCallID, content, state) + s.annotateToolResult(before, req) + } + case "activity": + content := copyOutboundEvent(req.Content) + if len(content) == 0 { + content = OutboundEvent{} + if req.Text != "" { + content["text"] = req.Text + } + if req.State != "" { + content["state"] = req.State + } + if req.Value != nil { + content["value"] = req.Value + } + } + s.addEvent(agui.NewEvent(map[string]any{ + "type": agui.EventActivitySnapshot, + "messageId": s.run.MessageID, + "activityType": firstNonEmpty(req.ActivityType, req.Name, "activity"), + "content": map[string]any(content), + "replace": req.Replace, + })) + case "activity_delta": + s.addEvent(agui.NewEvent(map[string]any{ + "type": agui.EventActivityDelta, + "messageId": s.run.MessageID, + "activityType": firstNonEmpty(req.ActivityType, req.Name, "activity"), + "patch": firstNonNil(req.Patch, req.Delta, req.Value), + })) + case "state_delta": + s.writer.StateDelta(firstNonNil(req.Delta, req.Value)) + case "state_snapshot": + s.addEvent(agui.NewEvent(map[string]any{ + "type": agui.EventStateSnapshot, + "snapshot": req.Value, + })) + case "raw": + s.addEvent(agui.NewEvent(map[string]any{ + "type": agui.EventRaw, + "source": req.Source, + "event": firstNonNil(req.Value, req.Content), + })) + case "custom": + s.writer.Custom(firstNonEmpty(req.Name, "openclaw.data"), req.Value) + case "step_start", "step": + s.writer.StepStart(firstNonEmpty(req.Name, req.Text, "step")) + case "step_finish", "step_end": + s.writer.StepFinish(firstNonEmpty(req.Name, req.Text, "step")) + default: + return fmt.Errorf("unsupported Beeper AI run stream part kind %q", req.Kind) + } + return nil +} + +func (s *beeperAIRunState) ensureToolStarted(req MatrixBeeperAIRunPartOptions) (string, string) { + toolName := firstNonEmpty(req.ToolName, req.Name, "tool") + toolCallID := firstNonEmpty(req.ToolCallID, "tool:"+toolName) + if req.Input != nil { + s.toolInputs[toolCallID] = req.Input + } + if toolName != "" { + s.toolNames[toolCallID] = toolName + } + if s.startedToolCalls[toolCallID] { + return toolCallID, firstNonEmpty(s.toolNames[toolCallID], toolName) + } + s.startedToolCalls[toolCallID] = true + title := toolTitle(req) + metadata := toolMetadata(req, title) + s.writer.ToolStartWithMetadata(toolCallID, toolName, beeperAIRunPartIndex(req), nil, metadata) + s.annotateToolStart(req, title) + input := firstNonNil(req.Input, req.Value) + if input != nil { + s.toolInputs[toolCallID] = input + if delta := firstNonEmpty(req.Text, stringFromAny(req.Delta), beeperAIJSONString(input)); delta != "" { + s.writer.ToolArgs(toolCallID, delta, input) + } + } + return toolCallID, toolName +} + +func (s *beeperAIRunState) annotateToolStart(req MatrixBeeperAIRunPartOptions, title string) { + if len(s.run.Events) == 0 { + return + } + event := s.run.Events[len(s.run.Events)-1] + if event.Type() != agui.EventToolCallStart { + return + } + if req.Approval != nil { + event.Set("approval", req.Approval) + } + if req.Dynamic != nil { + event.Set("dynamic", *req.Dynamic) + } + if req.ProviderExecuted != nil { + event.Set("providerExecuted", *req.ProviderExecuted) + } + if req.StartedAtMs != nil { + event.Set("startedAtMs", *req.StartedAtMs) + } + if title != "" { + event.Set("title", title) + } +} + +func (s *beeperAIRunState) annotateToolResult(before int, req MatrixBeeperAIRunPartOptions) { + for i := before; i < len(s.run.Events); i++ { + event := s.run.Events[i] + if event.Type() != agui.EventToolCallResult { + continue + } + if req.CompletedAtMs != nil { + event.Set("completedAtMs", *req.CompletedAtMs) + } + if req.Preliminary { + event.Set("preliminary", true) + } + if req.ProviderExecuted != nil { + event.Set("providerExecuted", *req.ProviderExecuted) + } + if req.ToolName != "" { + event.Set("toolName", req.ToolName) + } + } +} + +func (s *beeperAIRunState) endToolInput(toolCallID, toolName string, input any) { + if toolCallID == "" || s.endedToolInputs[toolCallID] { + return + } + s.endedToolInputs[toolCallID] = true + s.writer.ToolInputComplete(toolCallID, firstNonEmpty(toolName, s.toolNames[toolCallID], "tool"), input) +} + +func toolTitle(req MatrixBeeperAIRunPartOptions) string { + return firstNonEmpty(req.Title, commandFromPart(req)) +} + +func toolMetadata(req MatrixBeeperAIRunPartOptions, title string) map[string]any { + metadata := map[string]any(nil) + if len(req.Metadata) > 0 { + metadata = map[string]any(req.Metadata) + } + if title == "" && req.Description == "" { + return metadata + } + if metadata == nil { + metadata = map[string]any{} + } + if title != "" && firstString(metadata["displayName"], "") == "" { + metadata["displayName"] = title + } + if req.Description != "" && firstString(metadata["description"], "") == "" { + metadata["description"] = req.Description + } + return metadata +} + +func isCommandToolName(toolName string) bool { + switch strings.ToLower(strings.TrimSpace(toolName)) { + case "bash", "command", "exec", "shell": + return true + default: + return false + } +} + +func commandToolOutput(req MatrixBeeperAIRunPartOptions) any { + details := mapFromAny(req.Details) + if details == nil { + if result := mapFromAny(req.Result); result != nil { + details = mapFromAny(result["details"]) + } + } + if details == nil { + if output := mapFromAny(req.Output); output != nil { + details = mapFromAny(output["details"]) + } + } + if details == nil { + if response := mapFromAny(req.Response); response != nil { + details = mapFromAny(response["details"]) + } + } + if details != nil { + output := firstNonEmpty( + stringFromAny(details["aggregated"]), + stringFromAny(details["output"]), + stringFromAny(req.Output), + stringFromAny(req.Response), + ) + result := stripNil(map[string]any{ + "status": firstNonEmpty(req.Status, stringFromAny(details["status"])), + "exitCode": firstNonNil(req.ExitCode, intFromAny(details["exitCode"]), intFromAny(details["exit_code"])), + "durationMs": firstNonNil(intFromAny(details["durationMs"]), intFromAny(details["duration_ms"])), + "cwd": firstNonEmpty(req.Cwd, stringFromAny(details["cwd"]), stringFromAny(mapFromAny(req.Input)["cwd"])), + "command": firstNonEmpty(req.Command, stringFromAny(details["command"]), commandFromPart(req)), + "stdout": firstNonEmpty(req.Stdout, stringFromAny(details["stdout"])), + "stderr": firstNonEmpty(req.Stderr, stringFromAny(details["stderr"])), + "aggregated": firstNonEmpty(req.Aggregated, stringFromAny(details["aggregated"])), + "output": output, + }) + if len(result) == 0 { + return nil + } + return result + } + + stdout := firstNonEmpty(req.Stdout, stringFromAny(mapFromAny(req.Result)["stdout"]), stringFromAny(mapFromAny(req.Output)["stdout"])) + stderr := firstNonEmpty(req.Stderr, stringFromAny(mapFromAny(req.Result)["stderr"]), stringFromAny(mapFromAny(req.Output)["stderr"])) + aggregated := firstNonEmpty(req.Aggregated, stringFromAny(mapFromAny(req.Result)["aggregated"]), stringFromAny(mapFromAny(req.Output)["aggregated"])) + output := firstCommandText(aggregated, req.Output, req.Result, req.Response, req.Value) + if output == "" && stdout == "" && stderr == "" { + return nil + } + if req.ExitCode == nil && stdout != "" && stderr == "" { + return stdout + } + if req.ExitCode == nil && stdout == "" && stderr != "" { + return stderr + } + if req.ExitCode == nil && stdout == "" && stderr == "" { + return output + } + return stripNil(map[string]any{ + "status": req.Status, + "exitCode": req.ExitCode, + "cwd": firstNonEmpty(req.Cwd, stringFromAny(mapFromAny(req.Input)["cwd"])), + "command": commandFromPart(req), + "stdout": stdout, + "stderr": stderr, + "aggregated": aggregated, + "output": output, + }) +} + +func commandFromPart(req MatrixBeeperAIRunPartOptions) string { + input := mapFromAny(req.Input) + value := mapFromAny(req.Value) + return firstNonEmpty( + req.Command, + stringFromAny(input["command"]), + stringFromAny(input["cmd"]), + stringFromAny(value["command"]), + stringFromAny(value["cmd"]), + ) +} + +func firstCommandText(values ...any) string { + for _, value := range values { + if text := commandText(value); text != "" { + return text + } + } + return "" +} + +func commandText(value any) string { + if text := stringFromAny(value); text != "" { + return text + } + record := mapFromAny(value) + if len(record) == 0 || isStatusOnlyToolOutput(record) { + return "" + } + return firstNonEmpty( + stringFromAny(record["stdout"]), + stringFromAny(record["stderr"]), + stringFromAny(record["aggregated"]), + stringFromAny(record["output"]), + stringFromAny(record["text"]), + stringFromAny(record["content"]), + stringFromAny(record["response"]), + ) +} + +func isStatusOnlyToolOutput(record map[string]any) bool { + if len(record) == 0 { + return false + } + for key := range record { + switch key { + case "action", "finalUrl", "final_url", "phase", "queries", "query", "queryUnavailable", "state", "status", "url": + default: + return false + } + } + return true +} + +func (s *beeperAIRunState) addEvent(event agui.Event) { + canonicalizeBeeperAIRunEvent(s.run, event) + if !event.Has("timestamp") { + event.Set("timestamp", time.Now().UnixMilli()) + } + s.writer.Add(event) +} + +func (c *Core) publishBeeperAIRunStreamPending(ctx context.Context, state *beeperAIRunState) error { + if state == nil || state.streamEventID == "" { + return errors.New("Beeper AI run is not attached to a stream message") + } + stream := c.beeperStreamMessages[state.streamEventID] + if stream == nil { + return fmt.Errorf("beeper stream message %s is not registered", state.streamEventID) + } + if state.published >= len(state.run.Events) { + return nil + } + partial := *state.run + partial.Events = append([]agui.Event(nil), state.run.Events[state.published:]...) + carriers, err := aistream.PackRunFromSeq(partial, stream.nextSeq) + if err != nil { + return err + } + contents := make([]map[string]any, 0, len(carriers)) + for _, carrier := range carriers { + contents = append(contents, aistream.CarrierContent(partial, carrier.Envelopes)) + } + if err := c.publishBeeperStreamCarrierContents(ctx, state.streamEventID, stream, contents); err != nil { + return err + } + stream.nextSeq = aistream.NextSeq(carriers) + state.published = len(state.run.Events) + return nil +} + +func (c *Core) finalizeBeeperAIRunStream(ctx context.Context, state *beeperAIRunState, events []OutboundEvent) ([]byte, error) { + if state == nil || state.streamEventID == "" { + return nil, errors.New("Beeper AI run is not attached to a stream message") + } + stream := c.beeperStreamMessages[state.streamEventID] + if stream == nil { + return nil, fmt.Errorf("beeper stream message %s is not registered", state.streamEventID) + } + projection := aimatrix.ProjectFinal(*state.run, nil) + clearBeeperAIWorkingFallback(projection.Content, *state.run) + if projection.NeedsAttachment { + partsRef, err := c.uploadBeeperAIFinalPartsRef(ctx, *state.run, projection.Message) + if err != nil { + return nil, err + } + projection = aimatrix.ProjectFinal(*state.run, partsRef) + clearBeeperAIWorkingFallback(projection.Content, *state.run) + } + contentMap := messageContentMap(projection.Content, OutboundEvent(projection.Extra)) + result, err := c.finalizeBeeperStreamMessage(ctx, MatrixFinalizeBeeperStreamMessageOptions{ + Body: projection.Content.Body, + Content: contentMap, + EventID: state.streamEventID.String(), + RoomID: stream.roomID.String(), + TopLevelContent: OutboundEvent{"com.beeper.dont_render_edited": true}, + UserID: stream.userID, + }) + if err != nil { + return nil, err + } + delete(c.beeperAIRuns, state.run.RunID) + return c.marshalBeeperAIRunStreamResult(state, events, result.ReplacementEventID, result.Raw) +} + +func (c *Core) uploadBeeperAIFinalPartsRef(ctx context.Context, run aistream.Run, message aistream.UIMessage) (*aistream.FinalPartsRef, error) { + payload, err := json.Marshal(run.FinalPartsPayload(message)) + if err != nil { + return nil, fmt.Errorf("failed to encode Beeper AI final parts: %w", err) + } + raw, err := c.uploadEncryptedMedia(ctx, MatrixUploadMediaOptions{ + ContentType: aistream.FinalPartsMediaType, + Filename: fmt.Sprintf("ai-final-parts-%s.json", run.RunID), + }, payload) + if err != nil { + return nil, fmt.Errorf("failed to upload Beeper AI final parts: %w", err) + } + var uploaded MatrixUploadEncryptedMediaResult + if err := json.Unmarshal(raw, &uploaded); err != nil { + return nil, err + } + hash := sha256.Sum256(payload) + return &aistream.FinalPartsRef{ + Schema: aistream.FinalPartsRefSchema, + MediaType: aistream.FinalPartsMediaType, + File: uploaded.File, + ByteSize: len(payload), + SHA256: base64.RawURLEncoding.EncodeToString(hash[:]), + PartsCount: len(message.Parts), + }, nil +} + +func clearBeeperAIWorkingFallback(content *event.MessageEventContent, run aistream.Run) { + if content == nil || strings.TrimSpace(run.Text()) != "" || strings.TrimSpace(run.Preview.Text) != "" || run.Status.State == "error" { + return + } + if strings.TrimSpace(content.Body) != "Working..." { + return + } + content.Body = "" + content.FormattedBody = "" +} + +func (c *Core) requireBeeperAIRun(runID string) (*beeperAIRunState, error) { + if strings.TrimSpace(runID) == "" { + return nil, errors.New("missing Beeper AI run ID") + } + state := c.beeperAIRuns[runID] + if state == nil { + return nil, errors.New("Beeper AI run is not registered") + } + return state, nil +} + +func beeperAIRunStreamContext(ctx context.Context) (context.Context, context.CancelFunc) { + if _, ok := ctx.Deadline(); ok { + return ctx, func() {} + } + return context.WithTimeout(ctx, beeperAIRunStreamOperationTimeout) +} + +func normalizeBeeperAIRunPartKind(kind string) string { + kind = strings.ToLower(strings.TrimSpace(kind)) + kind = strings.ReplaceAll(kind, "-", "_") + kind = strings.ReplaceAll(kind, ".", "_") + return kind +} + +func beeperAIRunPartIndex(req MatrixBeeperAIRunPartOptions) int { + if req.Index == nil { + return 0 + } + return *req.Index +} + +func firstNonNil(values ...any) any { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} + +func stringFromAny(value any) string { + text, _ := value.(string) + return text +} + +func beeperAIJSONString(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return text + } + raw, err := json.Marshal(value) + if err != nil { + return fmt.Sprint(value) + } + return string(raw) +} + +func mapFromAny(value any) map[string]any { + switch typed := value.(type) { + case nil: + return nil + case map[string]any: + return typed + case OutboundEvent: + return map[string]any(typed) + default: + return nil + } +} + +func intFromAny(value any) *int { + switch typed := value.(type) { + case int: + return &typed + case int32: + out := int(typed) + return &out + case int64: + out := int(typed) + return &out + case float64: + out := int(typed) + return &out + case json.Number: + raw, err := typed.Int64() + if err != nil { + return nil + } + out := int(raw) + return &out + default: + return nil + } +} + +func stripNil(input map[string]any) map[string]any { + for key, value := range input { + if value == nil || value == "" { + delete(input, key) + } + } + return input +} + +func canonicalizeBeeperAIRunEvent(run *aistream.Run, event agui.Event) { + if run == nil || event.Len() == 0 { + return + } + switch event.Type() { + case agui.EventRunStarted, agui.EventRunFinished, agui.EventRunError: + event.Set("runId", run.RunID) + event.Set("threadId", run.ThreadID) + case agui.EventTextMessageStart, agui.EventTextMessageContent, agui.EventTextMessageChunk, agui.EventTextMessageEnd, + agui.EventReasoningStart, agui.EventReasoningMsgStart, agui.EventReasoningMsgCont, agui.EventReasoningMsgChunk, agui.EventReasoningMsgEnd, agui.EventReasoningEnd, + agui.EventActivitySnapshot, agui.EventActivityDelta: + event.Set("messageId", run.MessageID) + case agui.EventToolCallStart: + event.Set("parentMessageId", run.MessageID) + } +} + +func (c *Core) marshalBeeperAIRunSnapshot(run *aistream.Run, events []OutboundEvent) ([]byte, error) { + return json.Marshal(c.beeperAIRunSnapshot(run, events)) +} + +func (c *Core) marshalBeeperAIRunStreamResult(state *beeperAIRunState, events []OutboundEvent, replacementEventID string, raw any) ([]byte, error) { + return json.Marshal(MatrixBeeperAIRunStreamResult{ + MatrixBeeperAIRunSnapshot: c.beeperAIRunSnapshot(state.run, events), + Descriptor: state.streamDescriptor, + EventID: state.streamEventID.String(), + Raw: raw, + ReplacementEventID: replacementEventID, + RoomID: state.streamRoomID.String(), + }) +} + +func (c *Core) beeperAIRunSnapshot(run *aistream.Run, events []OutboundEvent) MatrixBeeperAIRunSnapshot { + body := run.Preview.Text + if body == "" { + body = run.Text() + } + if body == "" { + body = "..." + } + return MatrixBeeperAIRunSnapshot{ + Body: body, + Events: events, + InitialAIMessage: run.InitialBeeperAIMessage(), + FinalAIMessage: run.FinalBeeperAIMessage(0, true), + Metadata: run.AI(aistream.AIKindStream), + MessageID: run.MessageID, + RunID: run.RunID, + ThreadID: run.ThreadID, + } +} + +func outboundEventsFromAGUI(events []agui.Event) []OutboundEvent { + out := make([]OutboundEvent, 0, len(events)) + for _, event := range events { + out = append(out, OutboundEvent(event.Map())) + } + return out +} diff --git a/packages/pickle/native/internal/core/beeper_ai_run_test.go b/packages/pickle/native/internal/core/beeper_ai_run_test.go new file mode 100644 index 0000000..f0986da --- /dev/null +++ b/packages/pickle/native/internal/core/beeper_ai_run_test.go @@ -0,0 +1,395 @@ +package core + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + aistream "github.com/beeper/ai-bridge/pkg/ai-stream" + aimatrix "github.com/beeper/ai-bridge/pkg/ai-stream/matrix" +) + +func TestBeeperAIRunLifecycleUsesAIBridgeFinalContent(t *testing.T) { + core := New(nil) + beginPayload, err := json.Marshal(MatrixBeginBeeperAIRunOptions{ + AgentID: "codex", + AgentName: "Codex", + Model: "openclaw/plugin", + RunID: "run-1", + ThreadID: "thread-1", + }) + if err != nil { + t.Fatal(err) + } + beginRaw, err := core.handleBeginBeeperAIRun(beginPayload) + if err != nil { + t.Fatal(err) + } + begin := decodeBeeperAIRunSnapshot(t, beginRaw) + if begin.RunID != "run-1" || begin.ThreadID != "thread-1" || begin.MessageID == "" { + t.Fatalf("unexpected begin identity: %#v", begin) + } + if got := eventTypes(begin.Events); strings.Join(got, ",") != "RUN_STARTED" { + t.Fatalf("unexpected begin events: %#v", got) + } + if begin.InitialAIMessage == nil || begin.Metadata == nil { + t.Fatalf("expected begin snapshot to include initial message and metadata: %#v", begin) + } + + appendPayload, err := json.Marshal(MatrixAppendBeeperAIRunEventOptions{ + RunID: "run-1", + Event: OutboundEvent{ + "delta": "hello", + "type": "TEXT_MESSAGE_CONTENT", + }, + }) + if err != nil { + t.Fatal(err) + } + appendRaw, err := core.handleAppendBeeperAIRunEvent(appendPayload) + if err != nil { + t.Fatal(err) + } + appendSnap := decodeBeeperAIRunSnapshot(t, appendRaw) + if appendSnap.Body != "hello" { + t.Fatalf("append body = %q, want hello", appendSnap.Body) + } + if got := eventTypes(appendSnap.Events); strings.Join(got, ",") != "TEXT_MESSAGE_CONTENT" { + t.Fatalf("unexpected append events: %#v", got) + } + if _, ok := appendSnap.Events[0]["timestamp"]; !ok { + t.Fatalf("append event missing native timestamp: %#v", appendSnap.Events[0]) + } + if appendSnap.Events[0]["messageId"] != begin.MessageID { + t.Fatalf("append event messageId = %v, want %s", appendSnap.Events[0]["messageId"], begin.MessageID) + } + + finishPayload, err := json.Marshal(MatrixFinishBeeperAIRunOptions{ + FinishReason: "stop", + RunID: "run-1", + }) + if err != nil { + t.Fatal(err) + } + finishRaw, err := core.handleFinishBeeperAIRun(finishPayload) + if err != nil { + t.Fatal(err) + } + finish := decodeBeeperAIRunSnapshot(t, finishRaw) + if finish.Body != "hello" { + t.Fatalf("finish body = %q, want hello", finish.Body) + } + if got := eventTypes(finish.Events); strings.Join(got, ",") != "MESSAGES_SNAPSHOT,RUN_FINISHED" { + t.Fatalf("unexpected finish events: %#v", got) + } + finalMessage, ok := finish.FinalAIMessage.(map[string]any) + if !ok { + t.Fatalf("final message has unexpected shape: %#v", finish.FinalAIMessage) + } + parts, ok := finalMessage["parts"].([]any) + if !ok || len(parts) != 1 { + t.Fatalf("final message parts have unexpected shape: %#v", finalMessage["parts"]) + } + textPart, ok := parts[0].(map[string]any) + if !ok || textPart["type"] != "text" || textPart["content"] != "hello" { + t.Fatalf("final text part has unexpected shape: %#v", parts[0]) + } +} + +func TestBeeperAIRunErrorAbortAndDelete(t *testing.T) { + core := New(nil) + beginPayload, err := json.Marshal(MatrixBeginBeeperAIRunOptions{RunID: "run-error", ThreadID: "thread-error"}) + if err != nil { + t.Fatal(err) + } + if _, err := core.handleBeginBeeperAIRun(beginPayload); err != nil { + t.Fatal(err) + } + errorPayload, err := json.Marshal(MatrixErrorBeeperAIRunOptions{ + Message: "user stopped it", + RunID: "run-error", + Type: "abort", + }) + if err != nil { + t.Fatal(err) + } + errorRaw, err := core.handleErrorBeeperAIRun(errorPayload) + if err != nil { + t.Fatal(err) + } + errorSnap := decodeBeeperAIRunSnapshot(t, errorRaw) + if got := eventTypes(errorSnap.Events); strings.Join(got, ",") != "MESSAGES_SNAPSHOT,RUN_ERROR" { + t.Fatalf("unexpected error events: %#v", got) + } + errorEvent := errorSnap.Events[len(errorSnap.Events)-1] + if errorEvent["type"] != "RUN_ERROR" || errorEvent["message"] != "user stopped it" { + t.Fatalf("unexpected error event payload: %#v", errorEvent) + } + deletePayload, err := json.Marshal(MatrixDeleteBeeperAIRunOptions{RunID: "run-error"}) + if err != nil { + t.Fatal(err) + } + if _, err := core.handleDeleteBeeperAIRun(deletePayload); err != nil { + t.Fatal(err) + } + if _, err := core.handleFinishBeeperAIRun([]byte(`{"runId":"run-error"}`)); err == nil { + t.Fatal("expected deleted run to be unavailable") + } +} + +func TestBeeperAIRunBeginRejectsMissingAndDuplicateRunIDs(t *testing.T) { + core := New(nil) + if _, err := core.handleBeginBeeperAIRun([]byte(`{"runId":" "}`)); err == nil { + t.Fatal("expected missing run ID to fail") + } + payload, err := json.Marshal(MatrixBeginBeeperAIRunOptions{RunID: "run-duplicate", ThreadID: "thread-duplicate"}) + if err != nil { + t.Fatal(err) + } + if _, err := core.handleBeginBeeperAIRun(payload); err != nil { + t.Fatal(err) + } + if _, err := core.handleBeginBeeperAIRun(payload); err == nil { + t.Fatal("expected duplicate run ID to fail") + } +} + +func TestBeeperAIRunCloseClearsRunState(t *testing.T) { + core := New(func(OutboundEvent) {}) + if _, err := core.handleBeginBeeperAIRun([]byte(`{"runId":"run-close","threadId":"thread-close"}`)); err != nil { + t.Fatal(err) + } + if len(core.beeperAIRuns) != 1 { + t.Fatalf("expected one active run before close, got %d", len(core.beeperAIRuns)) + } + if _, err := core.handleClose(); err != nil { + t.Fatal(err) + } + if len(core.beeperAIRuns) != 0 { + t.Fatalf("expected close to clear active runs, got %d", len(core.beeperAIRuns)) + } +} + +func TestBeeperAIRunSemanticPartsUseAIBridgeWriter(t *testing.T) { + core := New(nil) + state := mustBeginBeeperAIRun(t, core, MatrixBeginBeeperAIRunOptions{RunID: "run-parts", ThreadID: "thread-parts"}) + providerExecuted := true + startedAtMs := int64(123) + completedAtMs := int64(456) + parts := []MatrixBeeperAIRunPartOptions{ + {Kind: "text", Text: "hello"}, + {Kind: "reasoning", Text: "checking"}, + {Description: "Searches project documentation", Input: map[string]any{"query": "docs"}, Kind: "tool_start", Metadata: OutboundEvent{"source": "codex"}, ProviderExecuted: &providerExecuted, StartedAtMs: &startedAtMs, Title: "Search docs", ToolCallID: "tool-1", ToolName: "search"}, + {CompletedAtMs: &completedAtMs, Kind: "tool_result", Output: map[string]any{"ok": true}, ProviderExecuted: &providerExecuted, ToolCallID: "tool-1", ToolName: "search"}, + {Kind: "activity", ActivityType: "status", Content: OutboundEvent{"text": "Working..."}}, + {Kind: "custom", Name: "com.beeper.source", Value: map[string]any{"url": "https://example.com"}}, + } + for _, part := range parts { + if err := state.appendPart(part); err != nil { + t.Fatalf("appendPart(%s): %v", part.Kind, err) + } + } + types := eventTypes(outboundEventsFromAGUI(state.run.Events)) + want := []string{ + "RUN_STARTED", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "REASONING_START", + "REASONING_MESSAGE_START", + "REASONING_MESSAGE_CONTENT", + "TOOL_CALL_START", + "TOOL_CALL_ARGS", + "TOOL_CALL_END", + "TOOL_CALL_RESULT", + "ACTIVITY_SNAPSHOT", + "CUSTOM", + } + if strings.Join(types, ",") != strings.Join(want, ",") { + t.Fatalf("unexpected semantic event types:\n got %v\nwant %v", types, want) + } + events := outboundEventsFromAGUI(state.run.Events) + start := firstEventOfType(events, "TOOL_CALL_START") + if start["providerExecuted"] != true || fmt.Sprint(start["startedAtMs"]) != "123" || start["title"] != "Search docs" { + t.Fatalf("tool start lost rich fields: %#v", start) + } + if metadata, ok := start["metadata"].(map[string]any); !ok || metadata["source"] != "codex" || metadata["description"] != "Searches project documentation" { + t.Fatalf("tool start lost metadata: %#v", start) + } + result := firstEventOfType(events, "TOOL_CALL_RESULT") + if result["providerExecuted"] != true || fmt.Sprint(result["completedAtMs"]) != "456" || result["toolName"] != "search" { + t.Fatalf("tool result lost rich fields: %#v", result) + } + finalPart := firstToolPart(state.run.FinalBeeperAIMessage(0, true).Parts, "tool-1") + if finalPart == nil { + t.Fatalf("final message is missing tool part: %#v", state.run.FinalBeeperAIMessage(0, true).Parts) + } + finalMetadata, _ := finalPart["metadata"].(map[string]any) + if finalPart["providerExecuted"] != true || fmt.Sprint(finalPart["startedAtMs"]) != "123" || fmt.Sprint(finalPart["completedAtMs"]) != "456" || finalPart["title"] != "Search docs" || finalMetadata["description"] != "Searches project documentation" { + t.Fatalf("final tool part lost rich fields: %#v", finalPart) + } +} + +func TestBeeperAIRunEmptyToolResultCompletesToolCall(t *testing.T) { + core := New(nil) + state := mustBeginBeeperAIRun(t, core, MatrixBeginBeeperAIRunOptions{RunID: "run-empty-tool-result", ThreadID: "thread-empty-tool-result"}) + parts := []MatrixBeeperAIRunPartOptions{ + {Input: map[string]any{"command": "gog auth list --json --no-input"}, Kind: "tool_start", ToolCallID: "cmd-1", ToolName: "bash"}, + {Kind: "tool_result", State: "complete", ToolCallID: "cmd-1", ToolName: "bash"}, + } + for _, part := range parts { + if err := state.appendPart(part); err != nil { + t.Fatalf("appendPart(%s): %v", part.Kind, err) + } + } + events := outboundEventsFromAGUI(state.run.Events) + result := firstEventOfType(events, "TOOL_CALL_RESULT") + if result == nil { + t.Fatalf("empty tool result was dropped: %#v", events) + } + if fmt.Sprint(result["content"]) != "" { + t.Fatalf("empty tool result should not invent output: %#v", result) + } + finalPart := firstToolPart(state.run.FinalBeeperAIMessage(0, true).Parts, "cmd-1") + if finalPart == nil { + t.Fatalf("final message is missing completed tool part: %#v", state.run.FinalBeeperAIMessage(0, true).Parts) + } + if strings.Contains(fmt.Sprint(finalPart), "run finalized before tool completed") || strings.Contains(fmt.Sprint(finalPart), "failed") { + t.Fatalf("completed tool part leaked synthetic failure: %#v", finalPart) + } +} + +func TestBeeperAIRunCommandPartUsesCommandAsTitleAndActualOutput(t *testing.T) { + core := New(nil) + state := mustBeginBeeperAIRun(t, core, MatrixBeginBeeperAIRunOptions{RunID: "run-command", ThreadID: "thread-command"}) + err := state.appendPart(MatrixBeeperAIRunPartOptions{ + Input: map[string]any{ + "command": `/bin/zsh -lc "date '+%Y-%m-%d %H:%M:%S %Z'"`, + "cwd": "/Users/batuhan/.openclaw/workspace", + }, + Kind: "tool_result", + Output: map[string]any{"status": "completed"}, + Response: "2026-06-02 03:15:00 CEST", + Status: "completed", + ToolCallID: "cmd-date", + ToolName: "bash", + }) + if err != nil { + t.Fatal(err) + } + events := outboundEventsFromAGUI(state.run.Events) + start := firstEventOfType(events, "TOOL_CALL_START") + if start["title"] != `/bin/zsh -lc "date '+%Y-%m-%d %H:%M:%S %Z'"` { + t.Fatalf("command title not promoted: %#v", start) + } + metadata, ok := start["metadata"].(map[string]any) + if !ok || metadata["displayName"] != start["title"] { + t.Fatalf("command display metadata missing: %#v", start) + } + result := firstEventOfType(events, "TOOL_CALL_RESULT") + if result["content"] != "2026-06-02 03:15:00 CEST" { + t.Fatalf("command result should be actual output, got %#v", result) + } + if strings.Contains(fmt.Sprint(result["content"]), "completed") { + t.Fatalf("command result leaked status wrapper: %#v", result) + } +} + +func TestBeeperAIEmptyStreamProjectionDoesNotExposeWorkingFallback(t *testing.T) { + core := New(nil) + state := mustBeginBeeperAIRun(t, core, MatrixBeginBeeperAIRunOptions{RunID: "run-empty", ThreadID: "thread-empty"}) + + anchorContent, _ := aimatrix.AnchorContent(*state.run) + clearBeeperAIWorkingFallback(anchorContent, *state.run) + if strings.Contains(anchorContent.Body, "Working") || strings.Contains(anchorContent.FormattedBody, "Working") { + t.Fatalf("empty stream anchor leaked working fallback: %#v", anchorContent) + } + + state.writer.Finish("stop") + finalProjection := aimatrix.ProjectFinal(*state.run, nil) + clearBeeperAIWorkingFallback(finalProjection.Content, *state.run) + if strings.Contains(finalProjection.Content.Body, "Working") || strings.Contains(finalProjection.Content.FormattedBody, "Working") { + t.Fatalf("empty final projection leaked working fallback: %#v", finalProjection.Content) + } +} + +func TestBeeperStreamCarrierContentsUsesBeeperAIPayloadAndAdvancesSeq(t *testing.T) { + core := New(nil) + contents, nextSeq, err := core.beeperStreamCarrierContents("com.beeper.llm", MatrixPublishBeeperStreamMessagePartOptions{ + AgentID: "codex", + EventID: "$stream", + Part: OutboundEvent{ + "delta": "hello", + "messageId": "msg-1", + "runId": "run-1", + "threadId": "thread-1", + "type": "TEXT_MESSAGE_CONTENT", + }, + TurnID: "run-1", + }, 7) + if err != nil { + t.Fatal(err) + } + if len(contents) != 1 { + t.Fatalf("expected one carrier, got %d", len(contents)) + } + if nextSeq != 7+len(contents) { + t.Fatalf("next seq = %d, want %d", nextSeq, 7+len(contents)) + } + for index, content := range contents { + payload, ok := content[aistream.BeeperAIKey].(aistream.BeeperAI) + if !ok || len(payload.Events) != 1 { + t.Fatalf("carrier %d has unexpected payload shape: %#v", index, content) + } + wantSeq := 7 + index + if payload.Events[0].Seq != wantSeq { + t.Fatalf("carrier %d seq = %d, want %d", index, payload.Events[0].Seq, wantSeq) + } + } +} + +func decodeBeeperAIRunSnapshot(t *testing.T, raw []byte) MatrixBeeperAIRunSnapshot { + t.Helper() + var snapshot MatrixBeeperAIRunSnapshot + if err := json.Unmarshal(raw, &snapshot); err != nil { + t.Fatal(err) + } + return snapshot +} + +func mustBeginBeeperAIRun(t *testing.T, core *Core, req MatrixBeginBeeperAIRunOptions) *beeperAIRunState { + t.Helper() + state, err := core.beginBeeperAIRun(req) + if err != nil { + t.Fatal(err) + } + return state +} + +func eventTypes(events []OutboundEvent) []string { + types := make([]string, 0, len(events)) + for _, event := range events { + if eventType, ok := event["type"].(string); ok { + types = append(types, eventType) + } + } + return types +} + +func firstEventOfType(events []OutboundEvent, eventType string) OutboundEvent { + for _, event := range events { + if event["type"] == eventType { + return event + } + } + return nil +} + +func firstToolPart(parts []aistream.MessagePart, toolCallID string) aistream.MessagePart { + for _, part := range parts { + if part["toolCallId"] == toolCallID { + return part + } + } + return nil +} diff --git a/packages/pickle/native/internal/core/core.go b/packages/pickle/native/internal/core/core.go index 6b44715..b43addc 100644 --- a/packages/pickle/native/internal/core/core.go +++ b/packages/pickle/native/internal/core/core.go @@ -23,6 +23,7 @@ type Core struct { backupVersion id.KeyBackupVersion beeperStream *beeperstream.Helper beeperStreamMessages map[id.EventID]*beeperStreamMessage + beeperAIRuns map[string]*beeperAIRunState appserviceProcessor *beeperStreamEventProcessor emit func(OutboundEvent) host RuntimeHost @@ -54,6 +55,7 @@ func New(emit func(OutboundEvent), host ...RuntimeHost) *Core { return &Core{ emit: emit, host: runtimeHost, + beeperAIRuns: make(map[string]*beeperAIRunState), beeperStreamMessages: make(map[id.EventID]*beeperStreamMessage), emittedTimelineIDs: make(map[id.EventID]struct{}), messageEdits: make(map[id.EventID]*MatrixMessageEvent), @@ -92,6 +94,8 @@ func (c *Core) Handle(ctx context.Context, op string, payload []byte) ([]byte, e return c.handleAppserviceEnsureRegistered(ctx, payload) case opAppserviceEnsureJoined: return c.handleAppserviceEnsureJoined(ctx, payload) + case opAppserviceSetProfile: + return c.handleAppserviceSetProfile(ctx, payload) case opAppserviceCreateRoom: return c.handleAppserviceCreateRoom(ctx, payload) case opAppserviceCreatePortalRoom: @@ -138,6 +142,26 @@ func (c *Core) Handle(ctx context.Context, op string, payload []byte) ([]byte, e return c.handlePublishBeeperStreamMessagePart(ctx, payload) case opFinalizeBeeperStreamMessage: return c.handleFinalizeBeeperStreamMessage(ctx, payload) + case opBeginBeeperAIRun: + return c.handleBeginBeeperAIRun(payload) + case opAppendBeeperAIRunEvent: + return c.handleAppendBeeperAIRunEvent(payload) + case opFinishBeeperAIRun: + return c.handleFinishBeeperAIRun(payload) + case opErrorBeeperAIRun: + return c.handleErrorBeeperAIRun(payload) + case opDeleteBeeperAIRun: + return c.handleDeleteBeeperAIRun(payload) + case opStartBeeperAIRunStream: + return c.handleStartBeeperAIRunStream(ctx, payload) + case opAppendBeeperAIRunStreamEvent: + return c.handleAppendBeeperAIRunStreamEvent(ctx, payload) + case opAppendBeeperAIRunStreamPart: + return c.handleAppendBeeperAIRunStreamPart(ctx, payload) + case opFinishBeeperAIRunStream: + return c.handleFinishBeeperAIRunStream(ctx, payload) + case opErrorBeeperAIRunStream: + return c.handleErrorBeeperAIRunStream(ctx, payload) case opSetTyping: return c.handleSetTyping(ctx, payload) case opFetchMessage: @@ -160,6 +184,8 @@ func (c *Core) Handle(ctx context.Context, op string, payload []byte) ([]byte, e return c.handleCreateRoom(ctx, payload) case opFetchRoom: return c.handleFetchRoom(ctx, payload) + case opFetchRoomPowerLevels: + return c.handleFetchRoomPowerLevels(ctx, payload) case opFetchRoomState: return c.handleFetchRoomState(ctx, payload) case opFetchRoomStateEvent: @@ -256,6 +282,7 @@ func (c *Core) handleClose() ([]byte, error) { _ = c.beeperStream.Close() } c.beeperStream = nil + c.beeperAIRuns = make(map[string]*beeperAIRunState) c.appserviceProcessor = nil c.nextBatch = "" c.pendingDecryptions = nil diff --git a/packages/pickle/native/internal/core/messages.go b/packages/pickle/native/internal/core/messages.go index 272d4b6..cd73bf3 100644 --- a/packages/pickle/native/internal/core/messages.go +++ b/packages/pickle/native/internal/core/messages.go @@ -10,8 +10,8 @@ import ( "strconv" "time" - aistream "github.com/beeper/ai-bridge/pkg/ai-stream" agui "github.com/beeper/ai-bridge/pkg/ag-ui" + aistream "github.com/beeper/ai-bridge/pkg/ai-stream" "maunium.net/go/mautrix" mautrixbeeperstream "maunium.net/go/mautrix/beeperstream" "maunium.net/go/mautrix/event" @@ -117,8 +117,10 @@ type MatrixFinalizeBeeperStreamMessageResult struct { type beeperStreamMessage struct { descriptor *event.BeeperStreamInfo + direct bool nextSeq int roomID id.RoomID + userID string } func (c *Core) handleStartBeeperStreamMessage(ctx context.Context, payload []byte) ([]byte, error) { @@ -146,7 +148,13 @@ func (c *Core) handleStartBeeperStreamMessage(ctx context.Context, payload []byt if content["msgtype"] == nil { content["msgtype"] = "m.text" } - content["com.beeper.stream"] = descriptor + if len(req.Subscribers) > 0 { + content["com.beeper.stream"] = descriptor + } else { + content["com.beeper.stream"] = map[string]any{ + "type": req.StreamType, + } + } resp, err := c.sendBeeperStreamMessageEvent(ctx, req.RoomID, req.ThreadRootEventID, req.UserID, content) if err != nil { return nil, err @@ -157,8 +165,10 @@ func (c *Core) handleStartBeeperStreamMessage(ctx context.Context, payload []byt } c.beeperStreamMessages[eventID] = &beeperStreamMessage{ descriptor: descriptor.Clone(), + direct: len(req.Subscribers) > 0, nextSeq: 1, roomID: id.RoomID(req.RoomID), + userID: req.UserID, } c.addBeeperStreamSubscribers(ctx, id.RoomID(req.RoomID), eventID, req.Subscribers) c.client.Log.Debug(). @@ -226,18 +236,54 @@ func (c *Core) handlePublishBeeperStreamMessagePart(ctx context.Context, payload streamType = "com.beeper.llm" } seq := stream.nextSeq - content, err := c.beeperStreamCarrierContent(streamType, req, seq) + contents, nextSeq, err := c.beeperStreamCarrierContents(streamType, req, seq) if err != nil { return nil, err } - if err := c.beeperStream.Publish(ctx, stream.roomID, id.EventID(req.EventID), content); err != nil { + if err := c.publishBeeperStreamCarrierContents(ctx, id.EventID(req.EventID), stream, contents); err != nil { return nil, err } - stream.nextSeq = seq + 1 + stream.nextSeq = nextSeq return c.empty() } +func (c *Core) publishBeeperStreamCarrierContents(ctx context.Context, eventID id.EventID, stream *beeperStreamMessage, contents []map[string]any) error { + if stream == nil { + return fmt.Errorf("beeper stream message %s is not registered", eventID) + } + for _, content := range contents { + if stream.direct { + if err := c.beeperStream.Publish(ctx, stream.roomID, eventID, content); err != nil { + return err + } + } else { + content["body"] = "" + content["msgtype"] = "m.text" + content["m.relates_to"] = map[string]any{ + "rel_type": "m.reference", + "event_id": eventID.String(), + } + if _, err := c.sendBeeperStreamMessageEvent(ctx, stream.roomID.String(), "", stream.userID, content); err != nil { + return err + } + } + } + return nil +} + func (c *Core) beeperStreamCarrierContent(streamType string, req MatrixPublishBeeperStreamMessagePartOptions, seq int) (map[string]any, error) { + contents, _, err := c.beeperStreamCarrierContents(streamType, req, seq) + if err != nil { + return nil, err + } + if len(contents) == 0 { + return aistream.CarrierContent(aistream.Run{}, nil), nil + } + return contents[0], nil +} + +func (c *Core) beeperStreamCarrierContents(streamType string, req MatrixPublishBeeperStreamMessagePartOptions, seq int) ([]map[string]any, int, error) { + _ = streamType run := aistream.Run{ ThreadID: firstString(req.Part["threadId"], req.TurnID), RunID: firstString(req.Part["runId"], req.TurnID), @@ -245,22 +291,20 @@ func (c *Core) beeperStreamCarrierContent(streamType string, req MatrixPublishBe AgentID: firstNonEmpty(req.AgentID, "ai"), Model: firstString(req.Part["model"], aistream.DefaultModel), } - part := agui.Event(copyOutboundEvent(req.Part)) - if part["timestamp"] == nil { - part["timestamp"] = time.Now().UnixMilli() + part := agui.NewEvent(map[string]any(copyOutboundEvent(req.Part))) + if !part.Has("timestamp") { + part.Set("timestamp", time.Now().UnixMilli()) } - envelope, err := aistream.BuildEnvelope(run, seq, part, req.EventID) + run.Events = []agui.Event{part} + carriers, err := aistream.PackRunFromSeq(run, seq) if err != nil { - return nil, err + return nil, seq, err } - content := aistream.CarrierContent([]aistream.Envelope{envelope}) - if streamType != aistream.BeeperAIStreamKey { - if deltas, ok := content[aistream.BeeperAIStreamDeltas]; ok { - delete(content, aistream.BeeperAIStreamDeltas) - content[streamType+".deltas"] = deltas - } + contents := make([]map[string]any, 0, len(carriers)) + for _, carrier := range carriers { + contents = append(contents, aistream.CarrierContent(run, carrier.Envelopes)) } - return content, nil + return contents, aistream.NextSeq(carriers), nil } func (c *Core) handleFinalizeBeeperStreamMessage(ctx context.Context, payload []byte) ([]byte, error) { @@ -268,8 +312,16 @@ func (c *Core) handleFinalizeBeeperStreamMessage(ctx context.Context, payload [] if err := json.Unmarshal(payload, &req); err != nil { return nil, err } + result, err := c.finalizeBeeperStreamMessage(ctx, req) + if err != nil { + return nil, err + } + return json.Marshal(result) +} + +func (c *Core) finalizeBeeperStreamMessage(ctx context.Context, req MatrixFinalizeBeeperStreamMessageOptions) (MatrixFinalizeBeeperStreamMessageResult, error) { if req.RoomID == "" || req.EventID == "" { - return nil, errors.New("missing beeper stream finalize fields") + return MatrixFinalizeBeeperStreamMessageResult{}, errors.New("missing beeper stream finalize fields") } content := copyOutboundEvent(req.Content) if content["body"] == nil { @@ -278,12 +330,11 @@ func (c *Core) handleFinalizeBeeperStreamMessage(ctx context.Context, payload [] if content["msgtype"] == nil { content["msgtype"] = "m.text" } - content["com.beeper.stream"] = nil - topLevel := copyOutboundEvent(req.TopLevelContent) - topLevel["com.beeper.stream"] = nil + content = beeperStreamFinalEditExtra(content) + topLevel := mergeOutboundEvent(req.TopLevelContent, beeperStreamFinalEditTopLevelExtra()) replacement, err := c.sendBeeperStreamReplacementEvent(ctx, req.RoomID, req.EventID, req.UserID, content, topLevel) if err != nil { - return nil, err + return MatrixFinalizeBeeperStreamMessageResult{}, err } targetEventID := id.EventID(req.EventID) if c.beeperStream != nil { @@ -291,17 +342,33 @@ func (c *Core) handleFinalizeBeeperStreamMessage(ctx context.Context, payload [] c.beeperStream.Unsubscribe(id.RoomID(req.RoomID), targetEventID) } delete(c.beeperStreamMessages, targetEventID) - return json.Marshal(MatrixFinalizeBeeperStreamMessageResult{ + return MatrixFinalizeBeeperStreamMessageResult{ EventID: req.EventID, ReplacementEventID: replacement.EventID.String(), RoomID: req.RoomID, Raw: replacement, - }) + }, nil +} + +func beeperStreamFinalEditExtra(extra OutboundEvent) OutboundEvent { + out := make(OutboundEvent, len(extra)+1) + for key, value := range extra { + out[key] = value + } + out["com.beeper.stream"] = nil + return out +} + +func beeperStreamFinalEditTopLevelExtra() OutboundEvent { + return OutboundEvent{ + "com.beeper.dont_render_edited": true, + "com.beeper.stream": nil, + } } func (c *Core) sendBeeperStreamReplacementEvent(ctx context.Context, roomID, eventID, userID string, newContent, topLevel OutboundEvent) (*mautrix.RespSendEvent, error) { content := copyOutboundEvent(topLevel) - content["body"] = "" + content["body"] = firstString(newContent["body"], "") content["msgtype"] = firstString(newContent["msgtype"], "m.text") content["m.new_content"] = newContent content["m.relates_to"] = map[string]any{ @@ -819,6 +886,14 @@ func (c *Core) handleFetchThreadMessages(ctx context.Context, cli *mautrix.Clien return json.Marshal(OutboundEvent{"messages": messages, "nextCursor": nextCursor}) } +func mergeOutboundEvent(base, extra OutboundEvent) OutboundEvent { + out := copyOutboundEvent(base) + for key, value := range extra { + out[key] = value + } + return out +} + func (c *Core) applyLatestReplacement(ctx context.Context, cli *mautrix.Client, roomID id.RoomID, msg *MatrixMessageEvent) *MatrixMessageEvent { if msg == nil || boolValue(msg.IsEdited) { return msg diff --git a/packages/pickle/native/internal/core/messages_test.go b/packages/pickle/native/internal/core/messages_test.go index 6e5ed79..79dd850 100644 --- a/packages/pickle/native/internal/core/messages_test.go +++ b/packages/pickle/native/internal/core/messages_test.go @@ -3,8 +3,12 @@ package core import ( "context" "encoding/json" + "io" + "net/http" + "net/http/httptest" "testing" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -93,6 +97,69 @@ func TestProcessEventSkipsDuplicateTimelineEvents(t *testing.T) { } } +func TestFinalizeBeeperStreamMessageUsesAIBridgeFinalEditEnvelope(t *testing.T) { + requests := make(chan map[string]any, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var content map[string]any + if err := json.Unmarshal(body, &content); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + requests <- content + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"event_id":"$edit"}`)) + })) + t.Cleanup(server.Close) + + core := New(nil) + cli, err := mautrix.NewClient(server.URL, id.UserID("@bot:example"), "token") + if err != nil { + t.Fatal(err) + } + core.client = cli + + _, err = core.finalizeBeeperStreamMessage(context.Background(), MatrixFinalizeBeeperStreamMessageOptions{ + Content: OutboundEvent{ + "body": "done", + "com.beeper.ai": map[string]any{"kind": "final"}, + "msgtype": "m.text", + }, + EventID: "$stream", + RoomID: "!room:example", + TopLevelContent: OutboundEvent{ + "custom": "value", + }, + }) + if err != nil { + t.Fatal(err) + } + + replacement := <-requests + if replacement["body"] != "done" || replacement["msgtype"] != "m.text" { + t.Fatalf("unexpected replacement fallback content: %#v", replacement) + } + if replacement["com.beeper.dont_render_edited"] != true || replacement["custom"] != "value" { + t.Fatalf("replacement missing top-level final edit markers: %#v", replacement) + } + if stream, ok := replacement["com.beeper.stream"]; !ok || stream != nil { + t.Fatalf("replacement must clear top-level stream descriptor: %#v", replacement) + } + newContent, ok := replacement["m.new_content"].(map[string]any) + if !ok { + t.Fatalf("replacement missing m.new_content: %#v", replacement) + } + if stream, ok := newContent["com.beeper.stream"]; !ok || stream != nil { + t.Fatalf("replacement must clear stream descriptor in m.new_content: %#v", newContent) + } + if ai, ok := newContent["com.beeper.ai"].(map[string]any); !ok || ai["kind"] != "final" { + t.Fatalf("replacement lost final AI payload: %#v", newContent) + } + relatesTo, ok := replacement["m.relates_to"].(map[string]any) + if !ok || relatesTo["rel_type"] != "m.replace" || relatesTo["event_id"] != "$stream" { + t.Fatalf("replacement has unexpected relation: %#v", replacement["m.relates_to"]) + } +} + func TestProcessEncryptedEventEmitsDecryptionError(t *testing.T) { ctx := context.Background() var emitted []OutboundEvent diff --git a/packages/pickle/native/internal/core/operations.go b/packages/pickle/native/internal/core/operations.go index 5e9c608..b6a380e 100644 --- a/packages/pickle/native/internal/core/operations.go +++ b/packages/pickle/native/internal/core/operations.go @@ -23,6 +23,8 @@ const ( opAppserviceEnsureRegistered = "appservice_ensure_registered" // ts:operation appserviceEnsureJoined appservice_ensure_joined MatrixAppserviceRoomUserOptions void opAppserviceEnsureJoined = "appservice_ensure_joined" + // ts:operation appserviceSetProfile appservice_set_profile MatrixAppserviceSetProfileOptions void + opAppserviceSetProfile = "appservice_set_profile" // ts:operation appserviceCreateRoom appservice_create_room MatrixAppserviceCreateRoomOptions MatrixCreateRoomResult opAppserviceCreateRoom = "appservice_create_room" // ts:operation appserviceCreatePortalRoom appservice_create_portal_room MatrixAppserviceCreatePortalRoomOptions MatrixCreateRoomResult @@ -69,6 +71,26 @@ const ( opPublishBeeperStreamMessagePart = "publish_beeper_stream_message_part" // ts:operation finalizeBeeperStreamMessage finalize_beeper_stream_message MatrixFinalizeBeeperStreamMessageOptions MatrixFinalizeBeeperStreamMessageResult opFinalizeBeeperStreamMessage = "finalize_beeper_stream_message" + // ts:operation beginBeeperAIRun begin_beeper_ai_run MatrixBeginBeeperAIRunOptions MatrixBeeperAIRunSnapshot + opBeginBeeperAIRun = "begin_beeper_ai_run" + // ts:operation appendBeeperAIRunEvent append_beeper_ai_run_event MatrixAppendBeeperAIRunEventOptions MatrixBeeperAIRunSnapshot + opAppendBeeperAIRunEvent = "append_beeper_ai_run_event" + // ts:operation finishBeeperAIRun finish_beeper_ai_run MatrixFinishBeeperAIRunOptions MatrixBeeperAIRunSnapshot + opFinishBeeperAIRun = "finish_beeper_ai_run" + // ts:operation errorBeeperAIRun error_beeper_ai_run MatrixErrorBeeperAIRunOptions MatrixBeeperAIRunSnapshot + opErrorBeeperAIRun = "error_beeper_ai_run" + // ts:operation deleteBeeperAIRun delete_beeper_ai_run MatrixDeleteBeeperAIRunOptions void + opDeleteBeeperAIRun = "delete_beeper_ai_run" + // ts:operation startBeeperAIRunStream start_beeper_ai_run_stream MatrixStartBeeperAIRunStreamOptions MatrixBeeperAIRunStreamResult + opStartBeeperAIRunStream = "start_beeper_ai_run_stream" + // ts:operation appendBeeperAIRunStreamEvent append_beeper_ai_run_stream_event MatrixAppendBeeperAIRunEventOptions MatrixBeeperAIRunStreamResult + opAppendBeeperAIRunStreamEvent = "append_beeper_ai_run_stream_event" + // ts:operation appendBeeperAIRunStreamPart append_beeper_ai_run_stream_part MatrixAppendBeeperAIRunPartOptions MatrixBeeperAIRunStreamResult + opAppendBeeperAIRunStreamPart = "append_beeper_ai_run_stream_part" + // ts:operation finishBeeperAIRunStream finish_beeper_ai_run_stream MatrixFinishBeeperAIRunOptions MatrixBeeperAIRunStreamResult + opFinishBeeperAIRunStream = "finish_beeper_ai_run_stream" + // ts:operation errorBeeperAIRunStream error_beeper_ai_run_stream MatrixErrorBeeperAIRunOptions MatrixBeeperAIRunStreamResult + opErrorBeeperAIRunStream = "error_beeper_ai_run_stream" // ts:operation setTyping set_typing MatrixTypingOptions void opSetTyping = "set_typing" // ts:operation fetchMessage fetch_message MatrixFetchMessageOptions MatrixFetchMessageResult @@ -91,6 +113,8 @@ const ( opCreateRoom = "create_room" // ts:operation fetchRoom fetch_room MatrixFetchRoomOptions MatrixRoomInfo opFetchRoom = "fetch_room" + // ts:operation fetchRoomPowerLevels fetch_room_power_levels MatrixFetchRoomPowerLevelsOptions MatrixRoomPowerLevels + opFetchRoomPowerLevels = "fetch_room_power_levels" // ts:operation fetchRoomState fetch_room_state MatrixFetchRoomStateOptions MatrixFetchRoomStateResult opFetchRoomState = "fetch_room_state" // ts:operation fetchRoomStateEvent fetch_room_state_event MatrixFetchRoomStateEventOptions MatrixRoomStateEvent diff --git a/packages/pickle/native/internal/core/persistent_crypto_load.go b/packages/pickle/native/internal/core/persistent_crypto_load.go index 26f3b55..cf3fe70 100644 --- a/packages/pickle/native/internal/core/persistent_crypto_load.go +++ b/packages/pickle/native/internal/core/persistent_crypto_load.go @@ -107,7 +107,6 @@ func (store *persistentCryptoStore) applySnapshot(snapshot persistedCryptoSnapsh store.messageIndices = make(map[storedMessageIndexKey]storedMessageIndexValue, len(snapshot.MessageIndices)) for _, item := range snapshot.MessageIndices { store.messageIndices[storedMessageIndexKey{ - SenderKey: item.SenderKey, SessionID: item.SessionID, Index: item.Index, }] = storedMessageIndexValue{EventID: item.EventID, Timestamp: item.Timestamp} diff --git a/packages/pickle/native/internal/core/persistent_crypto_methods.go b/packages/pickle/native/internal/core/persistent_crypto_methods.go index 1e4c169..2c48265 100644 --- a/packages/pickle/native/internal/core/persistent_crypto_methods.go +++ b/packages/pickle/native/internal/core/persistent_crypto_methods.go @@ -71,9 +71,8 @@ func (store *persistentCryptoStore) MarkOutboundGroupSessionShared(ctx context.C return store.save(ctx) } -func (store *persistentCryptoStore) ValidateMessageIndex(ctx context.Context, senderKey id.SenderKey, sessionID id.SessionID, eventID id.EventID, index uint, timestamp int64) (bool, error) { +func (store *persistentCryptoStore) ValidateMessageIndex(ctx context.Context, sessionID id.SessionID, eventID id.EventID, index uint, timestamp int64) (bool, error) { key := storedMessageIndexKey{ - SenderKey: senderKey, SessionID: sessionID, Index: index, } diff --git a/packages/pickle/native/internal/core/persistent_crypto_snapshot.go b/packages/pickle/native/internal/core/persistent_crypto_snapshot.go index bb4cadf..ce5a4d5 100644 --- a/packages/pickle/native/internal/core/persistent_crypto_snapshot.go +++ b/packages/pickle/native/internal/core/persistent_crypto_snapshot.go @@ -102,7 +102,6 @@ func (store *persistentCryptoStore) snapshot() (persistedCryptoSnapshot, error) store.auxLock.Lock() for key, value := range store.messageIndices { snapshot.MessageIndices = append(snapshot.MessageIndices, persistedMessageIndex{ - SenderKey: key.SenderKey, SessionID: key.SessionID, Index: key.Index, EventID: value.EventID, diff --git a/packages/pickle/native/internal/core/persistent_crypto_store.go b/packages/pickle/native/internal/core/persistent_crypto_store.go index d4595c5..e5fd643 100644 --- a/packages/pickle/native/internal/core/persistent_crypto_store.go +++ b/packages/pickle/native/internal/core/persistent_crypto_store.go @@ -82,7 +82,6 @@ type persistedOutboundUserState struct { type storedMessageIndexKey struct { Index uint - SenderKey id.SenderKey SessionID id.SessionID } diff --git a/packages/pickle/native/internal/core/rooms.go b/packages/pickle/native/internal/core/rooms.go index 168d744..1357a3e 100644 --- a/packages/pickle/native/internal/core/rooms.go +++ b/packages/pickle/native/internal/core/rooms.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "strings" "time" @@ -17,9 +18,27 @@ type MatrixFetchRoomOptions struct { RoomID string `json:"roomId"` } +type MatrixFetchRoomPowerLevelsOptions struct { + RoomID string `json:"roomId"` +} + +type MatrixRoomPowerLevels struct { + Ban *float64 `json:"ban,omitempty"` + Events map[string]float64 `json:"events,omitempty"` + EventsDefault *float64 `json:"eventsDefault,omitempty"` + Invite *float64 `json:"invite,omitempty"` + Kick *float64 `json:"kick,omitempty"` + Notifications map[string]float64 `json:"notifications,omitempty"` + Raw map[string]any `json:"raw"` + Redact *float64 `json:"redact,omitempty"` + StateDefault *float64 `json:"stateDefault,omitempty"` + Users map[string]float64 `json:"users,omitempty"` + UsersDefault *float64 `json:"usersDefault,omitempty"` +} + type MatrixRoomStateInput struct { Content OutboundEvent `json:"content" tstype:"{ [key: string]: unknown }"` - StateKey string `json:"stateKey"` + StateKey *string `json:"stateKey,omitempty"` Type string `json:"type"` } @@ -124,32 +143,8 @@ func (c *Core) handleCreateRoom(ctx context.Context, payload []byte) ([]byte, er if err := json.Unmarshal(payload, &req); err != nil { return nil, err } - invitees := make([]id.UserID, 0, len(req.Invite)) - for _, userID := range req.Invite { - invitees = append(invitees, id.UserID(userID)) - } - initialState := make([]*event.Event, 0, len(req.InitialState)) - for _, state := range req.InitialState { - stateKey := state.StateKey - initialState = append(initialState, &event.Event{ - Type: event.NewEventType(state.Type), - StateKey: &stateKey, - Content: event.Content{Raw: state.Content}, - }) - } resp, err := retryMatrix(ctx, func() (*mautrix.RespCreateRoom, error) { - return cli.CreateRoom(ctx, &mautrix.ReqCreateRoom{ - CreationContent: req.CreationContent, - InitialState: initialState, - Invite: invitees, - IsDirect: req.IsDirect, - Name: req.Name, - Preset: req.Preset, - RoomAliasName: req.RoomAliasName, - RoomVersion: id.RoomVersion(req.RoomVersion), - Topic: req.Topic, - Visibility: req.Visibility, - }) + return cli.CreateRoom(ctx, makeCreateRoomRequest(req)) }) if err != nil { return nil, err @@ -277,6 +272,36 @@ func (c *Core) handleFetchRoomState(ctx context.Context, payload []byte) ([]byte return json.Marshal(MatrixFetchRoomStateResult{Events: converted, Raw: events}) } +func (c *Core) handleFetchRoomPowerLevels(ctx context.Context, payload []byte) ([]byte, error) { + cli, err := c.requireClient() + if err != nil { + return nil, err + } + var req MatrixFetchRoomPowerLevelsOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + var content map[string]any + if err := retryMatrixVoid(ctx, func() error { + return cli.StateEvent(ctx, id.RoomID(req.RoomID), event.StatePowerLevels, "", &content) + }); err != nil { + return nil, err + } + return json.Marshal(MatrixRoomPowerLevels{ + Ban: finiteNumber(content["ban"]), + Events: finiteNumberRecord(content["events"]), + EventsDefault: finiteNumber(content["events_default"]), + Invite: finiteNumber(content["invite"]), + Kick: finiteNumber(content["kick"]), + Notifications: finiteNumberRecord(content["notifications"]), + Raw: content, + Redact: finiteNumber(content["redact"]), + StateDefault: finiteNumber(content["state_default"]), + Users: finiteNumberRecord(content["users"]), + UsersDefault: finiteNumber(content["users_default"]), + }) +} + func (c *Core) handleFetchRoomStateEvent(ctx context.Context, payload []byte) ([]byte, error) { cli, err := c.requireClient() if err != nil { @@ -704,6 +729,31 @@ func (c *Core) convertRoomStateEvent(roomID string, evt *event.Event) MatrixRoom } } +func finiteNumber(value any) *float64 { + number, ok := value.(float64) + if !ok || math.IsNaN(number) || math.IsInf(number, 0) { + return nil + } + return &number +} + +func finiteNumberRecord(value any) map[string]float64 { + raw, ok := value.(map[string]any) + if !ok { + return nil + } + result := map[string]float64{} + for key, entry := range raw { + if number := finiteNumber(entry); number != nil { + result[key] = *number + } + } + if len(result) == 0 { + return nil + } + return result +} + func (c *Core) updateDirectChats(ctx context.Context, cli *mautrix.Client, userID id.UserID, roomID id.RoomID) { directChats := event.DirectChatsEventContent{} if err := retryMatrixVoid(ctx, func() error { diff --git a/packages/pickle/native/internal/core/rooms_test.go b/packages/pickle/native/internal/core/rooms_test.go index 11ff2ff..56f91a7 100644 --- a/packages/pickle/native/internal/core/rooms_test.go +++ b/packages/pickle/native/internal/core/rooms_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "maunium.net/go/mautrix" @@ -136,6 +137,55 @@ func TestFetchRoomUsesDirectAccountDataBeforeMemberCountFallback(t *testing.T) { } } +func TestFetchRoomPowerLevelsParsesFiniteNumericContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/state/m.room.power_levels/") { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "ban": 50, + "events": map[string]any{"m.room.name": 75, "bad": "ignored"}, + "events_default": 1, + "invite": 2, + "kick": 3, + "notifications": map[string]any{"room": 20}, + "redact": 4, + "state_default": 5, + "users": map[string]any{"@admin:example": 100}, + "users_default": 6, + }) + })) + defer server.Close() + + core := New(nil) + core.client, _ = mautrix.NewClient(server.URL, id.UserID("@alice:example"), "token") + + raw, err := core.handleFetchRoomPowerLevels(context.Background(), []byte(`{"roomId":"!room:example"}`)) + if err != nil { + t.Fatal(err) + } + var levels MatrixRoomPowerLevels + if err := json.Unmarshal(raw, &levels); err != nil { + t.Fatal(err) + } + if levels.Ban == nil || *levels.Ban != 50 { + t.Fatalf("expected ban 50, got %#v", levels.Ban) + } + if _, ok := levels.Events["bad"]; ok || levels.Events["m.room.name"] != 75 { + t.Fatalf("expected filtered events, got %#v", levels.Events) + } + if levels.Users["@admin:example"] != 100 { + t.Fatalf("expected user power 100, got %#v", levels.Users) + } + if levels.Notifications["room"] != 20 { + t.Fatalf("expected room notification power 20, got %#v", levels.Notifications) + } + if levels.StateDefault == nil || *levels.StateDefault != 5 || levels.UsersDefault == nil || *levels.UsersDefault != 6 { + t.Fatalf("expected defaults to be parsed, got state=%#v users=%#v", levels.StateDefault, levels.UsersDefault) + } +} + func writeMatrixNotFound(w http.ResponseWriter) { w.WriteHeader(http.StatusNotFound) _ = json.NewEncoder(w).Encode(map[string]string{ diff --git a/packages/pickle/package.json b/packages/pickle/package.json index e54a82b..20dcb98 100644 --- a/packages/pickle/package.json +++ b/packages/pickle/package.json @@ -63,7 +63,9 @@ "build": "npm run generate:types && tsdown && npm run build:wasm", "build:wasm": "mkdir -p dist && cd native && GOOS=js GOARCH=wasm CGO_ENABLED=0 go build -tags goolm -ldflags='-s -w' -o ../dist/pickle.wasm ./cmd/matrix-wasm && cp \"$(go env GOROOT)/lib/wasm/wasm_exec.js\" ../dist/wasm_exec.js", "clean": "rm -rf dist", - "generate:types": "cd native && go run ./cmd/matrix-ts-types", + "generate:types": "cd native && go run -tags goolm ./cmd/matrix-ts-types", + "prepublishOnly": "node ../../scripts/guard-pnpm-publish.mjs", + "test:go": "cd native && go test -tags goolm ./...", "test": "vitest run --coverage", "typecheck": "npm run generate:types && tsc --noEmit" }, diff --git a/packages/pickle/src/beeper/auth.test.ts b/packages/pickle/src/beeper/auth.test.ts index f885c64..d14499f 100644 --- a/packages/pickle/src/beeper/auth.test.ts +++ b/packages/pickle/src/beeper/auth.test.ts @@ -66,6 +66,79 @@ describe("beeper auth", () => { type: "org.matrix.login.jwt", }); }); + + it("can request Beeper account creation during email login", async () => { + const fetchImpl = vi.fn(async (url: URL | string) => { + const path = new URL(String(url)).pathname; + if (path === "/user/login") return Response.json({ request: "request-id", type: ["email"] }); + if (path === "/user/login/email") return Response.json({}); + if (path === "/user/login/response") return Response.json({ leadToken: "lead-token", usernameSuggestions: ["qatest123"] }); + if (path === "/user/register") return Response.json({ token: "beeper-jwt" }); + if (path === "/_matrix/client/v3/login") { + return Response.json({ + access_token: "access", + device_id: "DEVICE", + user_id: "@bot:beeper.com", + }); + } + return Response.json({ device_id: "DEVICE", user_id: "@bot:beeper.com" }); + }); + + await expect(createBeeperLogin({ + email: "bot@example.com", + fetch: fetchImpl as typeof fetch, + getLoginCode: () => "123456", + onlyExistingAccounts: false, + username: "bot", + })).resolves.toMatchObject({ + accessToken: "access", + userId: "@bot:beeper.com", + }); + + expect(await requestBody(fetchImpl, 1)).toMatchObject({ + onlyExistingAccounts: false, + }); + expect(await requestBody(fetchImpl, 2)).toMatchObject({ + onlyExistingAccounts: false, + }); + expect(await requestBody(fetchImpl, 3)).toEqual({ + acceptTerms: true, + appType: "pickle", + leadToken: "lead-token", + userLoginRequestId: "request-id", + username: "bot", + }); + expect(await requestBody(fetchImpl, 4)).toMatchObject({ + token: "beeper-jwt", + type: "org.matrix.login.jwt", + }); + }); + + it("accepts empty successful Beeper auth responses", async () => { + const fetchImpl = vi.fn(async (url: URL | string) => { + const path = new URL(String(url)).pathname; + if (path === "/user/login") return Response.json({ request: "request-id", type: ["email"] }); + if (path === "/user/login/email") return new Response("", { status: 200 }); + if (path === "/user/login/response") return Response.json({ token: "beeper-jwt" }); + if (path === "/_matrix/client/v3/login") { + return Response.json({ + access_token: "access", + device_id: "DEVICE", + user_id: "@bot:beeper.com", + }); + } + return Response.json({ device_id: "DEVICE", user_id: "@bot:beeper.com" }); + }); + + await expect(createBeeperLogin({ + email: "bot@example.com", + fetch: fetchImpl as typeof fetch, + getLoginCode: () => "123456", + })).resolves.toMatchObject({ + accessToken: "access", + userId: "@bot:beeper.com", + }); + }); }); async function requestBody(fetchImpl: ReturnType, index: number) { diff --git a/packages/pickle/src/beeper/auth.ts b/packages/pickle/src/beeper/auth.ts index bae2166..991eb71 100644 --- a/packages/pickle/src/beeper/auth.ts +++ b/packages/pickle/src/beeper/auth.ts @@ -3,12 +3,15 @@ import { loginWithMatrixToken, type MatrixAuthenticatedAccount } from "../auth"; export type BeeperEnvironment = "production" | "staging" | "dev" | "local"; export interface BeeperAuthOptions { + acceptTerms?: boolean; email: string; env?: BeeperEnvironment; fetch?: typeof fetch; getLoginCode?: () => Promise | string; initialDeviceDisplayName?: string; metadata?: Record; + onlyExistingAccounts?: boolean; + username?: string; } export interface BeeperAuthStartResult { @@ -36,9 +39,15 @@ export async function createBeeperLogin(options: BeeperAuthOptions): Promise { await beeperRequest(fetchImpl, domain, "/user/login/email", { appType: "pickle", email, - onlyExistingAccounts: true, + onlyExistingAccounts: options.onlyExistingAccounts ?? true, request: requestId, }); } @@ -96,20 +106,60 @@ export async function sendBeeperLoginCode( fetchImpl: typeof fetch, domain: string, requestId: string, - code: string + code: string, + options: { + acceptTerms?: boolean | undefined; + email?: string | undefined; + onlyExistingAccounts?: boolean; + username?: string | undefined; + } = {} ): Promise { const raw = await beeperRequest(fetchImpl, domain, "/user/login/response", { appType: "pickle", - onlyExistingAccounts: true, + onlyExistingAccounts: options.onlyExistingAccounts ?? true, request: requestId, response: code, }); + const loginToken = readOptionalString(raw, "token"); + if (loginToken) { + return { + loginToken, + raw, + }; + } + const leadToken = readOptionalString(raw, "leadToken"); + if (leadToken && options.onlyExistingAccounts === false) { + const registered = await registerBeeperUser(fetchImpl, domain, { + acceptTerms: options.acceptTerms ?? true, + leadToken, + requestId, + username: options.username ?? firstString(readArray(raw, "usernameSuggestions")) ?? usernameFromEmail(options.email), + }); + return { + loginToken: readRequiredString(registered, "token"), + raw: registered, + }; + } return { loginToken: readRequiredString(raw, "token"), raw, }; } +async function registerBeeperUser( + fetchImpl: typeof fetch, + domain: string, + options: { acceptTerms: boolean; leadToken: string; requestId: string; username: string } +): Promise { + return beeperRequest(fetchImpl, domain, "/user/register", { + acceptTerms: options.acceptTerms, + appType: "pickle", + leadToken: options.leadToken, + userLoginRequestId: options.requestId, + username: options.username, + }); +} + async function beeperRequest( fetchImpl: typeof fetch, domain: string, @@ -127,7 +177,13 @@ async function beeperRequest( if (!response.ok) { throw new Error(`Beeper auth failed: ${response.status} ${await response.text()}`); } - return response.json(); + const text = await response.text(); + if (!text.trim()) return {}; + try { + return JSON.parse(text); + } catch (error) { + throw new Error(`Beeper auth returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`); + } } function readRequiredString(value: unknown, key: string): string { @@ -147,9 +203,22 @@ function readOptionalString(value: unknown, key: string): string | undefined { } function readStringArray(value: unknown, key: string): string[] { + return readArray(value, key).filter((item): item is string => typeof item === "string"); +} + +function readArray(value: unknown, key: string): unknown[] { if (!value || typeof value !== "object") { return []; } const field = (value as Record)[key]; - return Array.isArray(field) ? field.filter((item): item is string => typeof item === "string") : []; + return Array.isArray(field) ? field : []; +} + +function firstString(values: unknown[]): string | undefined { + return values.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim(); +} + +function usernameFromEmail(email: string | undefined): string { + const local = email?.split("@")[0]?.replace(/\+/gu, "") ?? `pickle${Date.now()}`; + return local.toLowerCase().replace(/[^a-z0-9._=-]+/gu, "").slice(0, 30) || `pickle${Date.now()}`; } diff --git a/packages/pickle/src/client-types.ts b/packages/pickle/src/client-types.ts index d89bd6b..62cc9ad 100644 --- a/packages/pickle/src/client-types.ts +++ b/packages/pickle/src/client-types.ts @@ -77,10 +77,20 @@ import type { MatrixAppserviceInitOptions, MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, + MatrixAppserviceSetProfileOptions, MatrixAppserviceUserOptions, + MatrixAppendBeeperAIRunEventOptions, + MatrixAppendBeeperAIRunPartOptions, + MatrixBeginBeeperAIRunOptions, + MatrixBeeperAIRunSnapshot, + MatrixBeeperAIRunStreamResult, + MatrixDeleteBeeperAIRunOptions, + MatrixErrorBeeperAIRunOptions, MatrixFinalizeBeeperStreamMessageOptions, MatrixFinalizeBeeperStreamMessageResult, + MatrixFinishBeeperAIRunOptions, MatrixPublishBeeperStreamMessagePartOptions, + MatrixStartBeeperAIRunStreamOptions, MatrixStartBeeperStreamMessageOptions, MatrixStartBeeperStreamMessageResult, } from "./runtime-types"; @@ -122,6 +132,7 @@ export interface MatrixAppservice { init(options: MatrixAppserviceInitOptions): Promise; applyTransaction(options: { transaction: Record }): Promise; sendMessage(options: MatrixAppserviceSendMessageOptions): Promise; + setProfile(options: MatrixAppserviceSetProfileOptions): Promise; } export interface MatrixRaw { @@ -144,6 +155,20 @@ export interface MatrixReceipts { } export interface MatrixBeeper { + aiRuns: { + appendEvent(options: MatrixAppendBeeperAIRunEventOptions): Promise; + begin(options: MatrixBeginBeeperAIRunOptions): Promise; + delete(options: MatrixDeleteBeeperAIRunOptions): Promise; + error(options: MatrixErrorBeeperAIRunOptions): Promise; + finish(options: MatrixFinishBeeperAIRunOptions): Promise; + }; + aiRunStreams: { + appendEvent(options: MatrixAppendBeeperAIRunEventOptions): Promise; + appendPart(options: MatrixAppendBeeperAIRunPartOptions): Promise; + error(options: MatrixErrorBeeperAIRunOptions): Promise; + finish(options: MatrixFinishBeeperAIRunOptions): Promise; + start(options: MatrixStartBeeperAIRunStreamOptions): Promise; + }; ephemeral: { send(options: SendBeeperEphemeralOptions): Promise; }; diff --git a/packages/pickle/src/client.test.ts b/packages/pickle/src/client.test.ts index 0d946c9..fe47783 100644 --- a/packages/pickle/src/client.test.ts +++ b/packages/pickle/src/client.test.ts @@ -915,7 +915,7 @@ describe("createMatrixClient", () => { content: { "com.beeper.ai": { id: expect.any(String), - parts: [{ text: "hello", type: "text" }], + parts: [{ content: "hello", type: "text" }], role: "assistant", }, }, @@ -957,6 +957,74 @@ describe("createMatrixClient", () => { expect(calls.map((call) => call.operation)).toContain("publish_beeper_stream_message_part"); }); + it("maps Beeper AI run helpers to the runtime contract", async () => { + const calls = installRuntime({ + append_beeper_ai_run_event: { body: "hello", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, + append_beeper_ai_run_stream_event: { body: "hello", descriptor: {}, eventId: "$stream", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, roomId: "!room", runId: "run", threadId: "thread" }, + append_beeper_ai_run_stream_part: { body: "hello", descriptor: {}, eventId: "$stream", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, roomId: "!room", runId: "run", threadId: "thread" }, + begin_beeper_ai_run: { body: "", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, + delete_beeper_ai_run: {}, + error_beeper_ai_run: { body: "failed", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, + error_beeper_ai_run_stream: { body: "failed", descriptor: {}, eventId: "$stream", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, replacementEventId: "$replace", roomId: "!room", runId: "run", threadId: "thread" }, + finish_beeper_ai_run: { body: "hello", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, + finish_beeper_ai_run_stream: { body: "hello", descriptor: {}, eventId: "$stream", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, replacementEventId: "$replace", roomId: "!room", runId: "run", threadId: "thread" }, + init: { deviceId: "DEVICE", userId: "@bot:example.com" }, + start_beeper_ai_run_stream: { body: "", descriptor: {}, eventId: "$stream", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, roomId: "!room", runId: "run", threadId: "thread" }, + }); + const client = createMatrixClient({ + homeserver: "https://matrix.beeper.com", + token: "token", + wasmModule: {} as WebAssembly.Module, + }); + + await client.beeper.aiRuns.begin({ agentName: "OpenClaw", runId: "run", threadId: "thread" }); + await client.beeper.aiRuns.appendEvent({ + event: { delta: "hello", messageId: "msg", type: "TEXT_MESSAGE_CONTENT" }, + runId: "run", + }); + await client.beeper.aiRuns.finish({ finishReason: "stop", runId: "run" }); + await client.beeper.aiRuns.error({ message: "failed", runId: "run", type: "error" }); + await client.beeper.aiRuns.delete({ runId: "run" }); + await client.beeper.aiRunStreams.start({ agentName: "OpenClaw", roomId: "!room", runId: "run", threadId: "thread" }); + await client.beeper.aiRunStreams.appendEvent({ + event: { delta: "hello", messageId: "msg", type: "TEXT_MESSAGE_CONTENT" }, + runId: "run", + }); + await client.beeper.aiRunStreams.appendPart({ kind: "text", runId: "run", text: "hello" }); + await client.beeper.aiRunStreams.finish({ finishReason: "stop", runId: "run" }); + await client.beeper.aiRunStreams.error({ message: "failed", runId: "run", type: "error" }); + + expect(calls.map((call) => call.operation)).toEqual([ + "init", + "begin_beeper_ai_run", + "append_beeper_ai_run_event", + "finish_beeper_ai_run", + "error_beeper_ai_run", + "delete_beeper_ai_run", + "start_beeper_ai_run_stream", + "append_beeper_ai_run_stream_event", + "append_beeper_ai_run_stream_part", + "finish_beeper_ai_run_stream", + "error_beeper_ai_run_stream", + ]); + expect(calls[1]?.payload).toEqual({ agentName: "OpenClaw", runId: "run", threadId: "thread" }); + expect(calls[2]?.payload).toEqual({ + event: { delta: "hello", messageId: "msg", type: "TEXT_MESSAGE_CONTENT" }, + runId: "run", + }); + expect(calls[3]?.payload).toEqual({ finishReason: "stop", runId: "run" }); + expect(calls[4]?.payload).toEqual({ message: "failed", runId: "run", type: "error" }); + expect(calls[5]?.payload).toEqual({ runId: "run" }); + expect(calls[6]?.payload).toEqual({ agentName: "OpenClaw", roomId: "!room", runId: "run", threadId: "thread" }); + expect(calls[7]?.payload).toEqual({ + event: { delta: "hello", messageId: "msg", type: "TEXT_MESSAGE_CONTENT" }, + runId: "run", + }); + expect(calls[8]?.payload).toEqual({ kind: "text", runId: "run", text: "hello" }); + expect(calls[9]?.payload).toEqual({ finishReason: "stop", runId: "run" }); + expect(calls[10]?.payload).toEqual({ message: "failed", runId: "run", type: "error" }); + }); + it("keeps accumulated UI message parts in the Beeper final edit", async () => { const calls = installRuntime({ finalize_beeper_stream_message: { eventId: "$message", raw: {}, replacementEventId: "$edit", roomId: "!room:example.com" }, @@ -998,10 +1066,10 @@ describe("createMatrixClient", () => { content: { "com.beeper.ai": { parts: [ - { state: "done", text: "thinking", type: "reasoning" }, + { content: "thinking", state: "done", type: "reasoning" }, { data: { stage: 1 }, id: "status", type: "data-status" }, { sourceId: "src-1", title: "Docs", type: "source-url", url: "https://example.com" }, - { state: "done", text: "hello", type: "text" }, + { content: "hello", state: "done", type: "text" }, ], role: "assistant", }, @@ -1036,7 +1104,7 @@ describe("createMatrixClient", () => { await client.streams.send({ finalAIMessage: { id: "final", - parts: [{ text: "override", type: "text" }], + parts: [{ content: "override", type: "text" }], role: "assistant", }, finalText: "override", @@ -1050,7 +1118,7 @@ describe("createMatrixClient", () => { content: { "com.beeper.ai": { id: "final", - parts: [{ text: "override", type: "text" }], + parts: [{ content: "override", type: "text" }], role: "assistant", }, }, diff --git a/packages/pickle/src/client.ts b/packages/pickle/src/client.ts index 9f8e76b..4ca3a4f 100644 --- a/packages/pickle/src/client.ts +++ b/packages/pickle/src/client.ts @@ -84,8 +84,23 @@ class DefaultMatrixClient implements MatrixClient { const result = await core.appserviceSendMessage(stripUndefined(opts)); return { eventId: result.eventId, raw: result.raw, roomId: result.roomId }; }), + setProfile: (opts) => this.#withCore((core) => core.appserviceSetProfile(stripUndefined(opts))), }; this.beeper = { + aiRuns: { + appendEvent: (opts) => this.#withCore((core) => core.appendBeeperAIRunEvent(stripUndefined(opts))), + begin: (opts) => this.#withCore((core) => core.beginBeeperAIRun(stripUndefined(opts))), + delete: (opts) => this.#withCore((core) => core.deleteBeeperAIRun(stripUndefined(opts))), + error: (opts) => this.#withCore((core) => core.errorBeeperAIRun(stripUndefined(opts))), + finish: (opts) => this.#withCore((core) => core.finishBeeperAIRun(stripUndefined(opts))), + }, + aiRunStreams: { + appendEvent: (opts) => this.#withCore((core) => core.appendBeeperAIRunStreamEvent(stripUndefined(opts))), + appendPart: (opts) => this.#withCore((core) => core.appendBeeperAIRunStreamPart(stripUndefined(opts))), + error: (opts) => this.#withCore((core) => core.errorBeeperAIRunStream(stripUndefined(opts))), + finish: (opts) => this.#withCore((core) => core.finishBeeperAIRunStream(stripUndefined(opts))), + start: (opts) => this.#withCore((core) => core.startBeeperAIRunStream(stripUndefined(opts))), + }, ephemeral: { send: (opts) => this.#withCore((core) => core.sendEphemeralEvent(stripUndefined({ @@ -176,11 +191,7 @@ class DefaultMatrixClient implements MatrixClient { ban: (opts) => this.#withCore((core) => core.banUser(opts)), create: (opts) => this.#withCore((core) => core.createRoom(stripUndefined({ creationContent: opts.creationContent, - initialState: opts.initialState?.map((state) => ({ - content: state.content, - stateKey: state.stateKey ?? "", - type: state.type, - })), + initialState: opts.initialState, invite: opts.invite, isDirect: opts.isDirect, name: opts.name, @@ -191,26 +202,7 @@ class DefaultMatrixClient implements MatrixClient { visibility: opts.visibility, }))), get: (opts) => this.#withCore((core) => core.fetchRoom(opts)), - getPowerLevels: async (opts) => { - const event = await this.#withCore((core) => core.fetchRoomStateEvent({ - eventType: "m.room.power_levels", - roomId: opts.roomId, - stateKey: "", - })); - return stripUndefined({ - ban: readNumber(event.content.ban), - events: readNumberRecord(event.content.events), - eventsDefault: readNumber(event.content.events_default), - invite: readNumber(event.content.invite), - kick: readNumber(event.content.kick), - notifications: readNumberRecord(event.content.notifications), - raw: event.content, - redact: readNumber(event.content.redact), - stateDefault: readNumber(event.content.state_default), - users: readNumberRecord(event.content.users), - usersDefault: readNumber(event.content.users_default), - }); - }, + getPowerLevels: (opts) => this.#withCore((core) => core.fetchRoomPowerLevels(opts)), getState: (opts) => this.#withCore((core) => core.fetchRoomState(opts)), getStateEvent: (opts) => this.#withCore((core) => core.fetchRoomStateEvent(stripUndefined({ eventType: opts.eventType, @@ -540,20 +532,3 @@ function eventRelationEventId(event: MatrixClientEvent): string | undefined { if ("relatesTo" in event) return event.relatesTo; return undefined; } - -function readNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function readNumberRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - const result: Record = {}; - for (const [key, entry] of Object.entries(value)) { - if (typeof entry === "number" && Number.isFinite(entry)) { - result[key] = entry; - } - } - return Object.keys(result).length > 0 ? result : undefined; -} diff --git a/packages/pickle/src/generated-runtime-operations.ts b/packages/pickle/src/generated-runtime-operations.ts index 34872e6..58f4cc0 100644 --- a/packages/pickle/src/generated-runtime-operations.ts +++ b/packages/pickle/src/generated-runtime-operations.ts @@ -2,6 +2,8 @@ import type { MatrixAccountDataResult, + MatrixAppendBeeperAIRunEventOptions, + MatrixAppendBeeperAIRunPartOptions, MatrixApplySyncResponseOptions, MatrixAppserviceBatchSendOptions, MatrixAppserviceBatchSendResult, @@ -12,30 +14,38 @@ import type { MatrixAppserviceInitOptions, MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, + MatrixAppserviceSetProfileOptions, MatrixAppserviceTransactionOptions, MatrixAppserviceUserOptions, MatrixBanUserOptions, + MatrixBeeperAIRunSnapshot, + MatrixBeeperAIRunStreamResult, + MatrixBeginBeeperAIRunOptions, MatrixCoreInitOptions, MatrixCreateRoomOptions, MatrixCreateRoomResult, MatrixCryptoStatus, + MatrixDeleteBeeperAIRunOptions, MatrixDeleteMessageOptions, MatrixDownloadEncryptedMediaOptions, MatrixDownloadMediaOptions, MatrixDownloadMediaResult, MatrixDownloadMediaThumbnailOptions, MatrixEditMessageOptions, + MatrixErrorBeeperAIRunOptions, MatrixFetchMessageOptions, MatrixFetchMessageResult, MatrixFetchMessagesOptions, MatrixFetchMessagesResult, MatrixFetchRoomMembersOptions, MatrixFetchRoomOptions, + MatrixFetchRoomPowerLevelsOptions, MatrixFetchRoomStateEventOptions, MatrixFetchRoomStateOptions, MatrixFetchRoomStateResult, MatrixFinalizeBeeperStreamMessageOptions, MatrixFinalizeBeeperStreamMessageResult, + MatrixFinishBeeperAIRunOptions, MatrixGetAccountDataOptions, MatrixGetRoomAccountDataOptions, MatrixGetUserOptions, @@ -63,6 +73,7 @@ import type { MatrixResolveRoomAliasResult, MatrixRoomInfo, MatrixRoomMembersResult, + MatrixRoomPowerLevels, MatrixRoomStateEvent, MatrixSendEphemeralEventOptions, MatrixSendMediaMessageOptions, @@ -75,6 +86,7 @@ import type { MatrixSetOwnAvatarURLOptions, MatrixSetOwnDisplayNameOptions, MatrixSetRoomAccountDataOptions, + MatrixStartBeeperAIRunStreamOptions, MatrixStartBeeperStreamMessageOptions, MatrixStartBeeperStreamMessageResult, MatrixSyncOnceOptions, @@ -100,6 +112,7 @@ export interface MatrixCoreOperations { initAppservice(options: MatrixAppserviceInitOptions): Promise; appserviceEnsureRegistered(options: MatrixAppserviceUserOptions): Promise; appserviceEnsureJoined(options: MatrixAppserviceRoomUserOptions): Promise; + appserviceSetProfile(options: MatrixAppserviceSetProfileOptions): Promise; appserviceCreateRoom(options: MatrixAppserviceCreateRoomOptions): Promise; appserviceCreatePortalRoom(options: MatrixAppserviceCreatePortalRoomOptions): Promise; appserviceCreateManagementRoom(options: MatrixAppserviceCreateManagementRoomOptions): Promise; @@ -123,6 +136,16 @@ export interface MatrixCoreOperations { startBeeperStreamMessage(options: MatrixStartBeeperStreamMessageOptions): Promise; publishBeeperStreamMessagePart(options: MatrixPublishBeeperStreamMessagePartOptions): Promise; finalizeBeeperStreamMessage(options: MatrixFinalizeBeeperStreamMessageOptions): Promise; + beginBeeperAIRun(options: MatrixBeginBeeperAIRunOptions): Promise; + appendBeeperAIRunEvent(options: MatrixAppendBeeperAIRunEventOptions): Promise; + finishBeeperAIRun(options: MatrixFinishBeeperAIRunOptions): Promise; + errorBeeperAIRun(options: MatrixErrorBeeperAIRunOptions): Promise; + deleteBeeperAIRun(options: MatrixDeleteBeeperAIRunOptions): Promise; + startBeeperAIRunStream(options: MatrixStartBeeperAIRunStreamOptions): Promise; + appendBeeperAIRunStreamEvent(options: MatrixAppendBeeperAIRunEventOptions): Promise; + appendBeeperAIRunStreamPart(options: MatrixAppendBeeperAIRunPartOptions): Promise; + finishBeeperAIRunStream(options: MatrixFinishBeeperAIRunOptions): Promise; + errorBeeperAIRunStream(options: MatrixErrorBeeperAIRunOptions): Promise; setTyping(options: MatrixTypingOptions): Promise; fetchMessage(options: MatrixFetchMessageOptions): Promise; fetchMessages(options: MatrixFetchMessagesOptions): Promise; @@ -134,6 +157,7 @@ export interface MatrixCoreOperations { downloadEncryptedMedia(options: MatrixDownloadEncryptedMediaOptions): Promise; createRoom(options: MatrixCreateRoomOptions): Promise; fetchRoom(options: MatrixFetchRoomOptions): Promise; + fetchRoomPowerLevels(options: MatrixFetchRoomPowerLevelsOptions): Promise; fetchRoomState(options: MatrixFetchRoomStateOptions): Promise; fetchRoomStateEvent(options: MatrixFetchRoomStateEventOptions): Promise; sendRoomStateEvent(options: MatrixSendRoomStateEventOptions): Promise; @@ -204,6 +228,10 @@ export abstract class MatrixCoreOperationCaller implements MatrixCoreOperations return this.call("appservice_ensure_joined", options); } + appserviceSetProfile(options: MatrixAppserviceSetProfileOptions): Promise { + return this.call("appservice_set_profile", options); + } + appserviceCreateRoom(options: MatrixAppserviceCreateRoomOptions): Promise { return this.call("appservice_create_room", options); } @@ -296,6 +324,46 @@ export abstract class MatrixCoreOperationCaller implements MatrixCoreOperations return this.call("finalize_beeper_stream_message", options); } + beginBeeperAIRun(options: MatrixBeginBeeperAIRunOptions): Promise { + return this.call("begin_beeper_ai_run", options); + } + + appendBeeperAIRunEvent(options: MatrixAppendBeeperAIRunEventOptions): Promise { + return this.call("append_beeper_ai_run_event", options); + } + + finishBeeperAIRun(options: MatrixFinishBeeperAIRunOptions): Promise { + return this.call("finish_beeper_ai_run", options); + } + + errorBeeperAIRun(options: MatrixErrorBeeperAIRunOptions): Promise { + return this.call("error_beeper_ai_run", options); + } + + deleteBeeperAIRun(options: MatrixDeleteBeeperAIRunOptions): Promise { + return this.call("delete_beeper_ai_run", options); + } + + startBeeperAIRunStream(options: MatrixStartBeeperAIRunStreamOptions): Promise { + return this.call("start_beeper_ai_run_stream", options); + } + + appendBeeperAIRunStreamEvent(options: MatrixAppendBeeperAIRunEventOptions): Promise { + return this.call("append_beeper_ai_run_stream_event", options); + } + + appendBeeperAIRunStreamPart(options: MatrixAppendBeeperAIRunPartOptions): Promise { + return this.call("append_beeper_ai_run_stream_part", options); + } + + finishBeeperAIRunStream(options: MatrixFinishBeeperAIRunOptions): Promise { + return this.call("finish_beeper_ai_run_stream", options); + } + + errorBeeperAIRunStream(options: MatrixErrorBeeperAIRunOptions): Promise { + return this.call("error_beeper_ai_run_stream", options); + } + setTyping(options: MatrixTypingOptions): Promise { return this.call("set_typing", options); } @@ -340,6 +408,10 @@ export abstract class MatrixCoreOperationCaller implements MatrixCoreOperations return this.call("fetch_room", options); } + fetchRoomPowerLevels(options: MatrixFetchRoomPowerLevelsOptions): Promise { + return this.call("fetch_room_power_levels", options); + } + fetchRoomState(options: MatrixFetchRoomStateOptions): Promise { return this.call("fetch_room_state", options); } diff --git a/packages/pickle/src/generated-runtime-types.ts b/packages/pickle/src/generated-runtime-types.ts index 0e5c8c0..a829968 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -53,6 +53,18 @@ export interface MatrixAppserviceRoomUserOptions { roomId: string; userId: string; } +export interface MatrixAppserviceSetProfileOptions { + avatarUrl?: string; + displayName?: string; + extra?: { [key: string]: unknown }; + identifiers?: string[]; + isBridgeBot?: boolean; + isNetworkBot?: boolean; + network?: string; + remoteId?: string; + service?: string; + userId: string; +} export interface MatrixAppserviceCreateRoomOptions extends MatrixCreateRoomOptions { userId?: string; } @@ -74,6 +86,7 @@ export interface MatrixAppserviceCreatePortalRoomOptions { autoJoinInvites?: boolean; bridge: MatrixAppserviceBridgeName; bridgeName?: string; + creationContent?: { [key: string]: unknown }; initialState?: MatrixRoomStateInput[]; initialMembers?: string[]; invite?: string[]; @@ -125,6 +138,101 @@ export interface MatrixAppserviceBatchSendResult { export interface MatrixAppserviceTransactionOptions { transaction: { [key: string]: unknown }; } +export interface MatrixBeginBeeperAIRunOptions { + agentId?: string; + agentName?: string; + data?: { [key: string]: unknown }; + messageId?: string; + model?: string; + runId?: string; + threadId?: string; +} +export interface MatrixAppendBeeperAIRunEventOptions { + event: { [key: string]: unknown }; + runId: string; +} +export interface MatrixBeeperAIRunPartOptions { + activityType?: string; + aggregated?: string; + approval?: unknown; + command?: string; + completedAtMs?: number /* int64 */; + content?: { [key: string]: unknown }; + cwd?: string; + description?: string; + delta?: unknown; + details?: unknown; + dynamic?: boolean; + error?: unknown; + exitCode?: number /* int */; + index?: number /* int */; + input?: unknown; + kind: "text" | "reasoning" | "reasoning_end" | "tool_start" | "tool_input" | "tool_end" | "tool_result" | "activity" | "activity_delta" | "state_delta" | "state_snapshot" | "raw" | "custom" | string; + metadata?: { [key: string]: unknown }; + name?: string; + output?: unknown; + patch?: unknown; + preliminary?: boolean; + providerExecuted?: boolean; + replace?: boolean; + response?: unknown; + result?: unknown; + source?: string; + state?: string; + status?: string; + startedAtMs?: number /* int64 */; + stderr?: string; + stdout?: string; + text?: string; + title?: string; + toolCallId?: string; + toolName?: string; + value?: unknown; +} +export interface MatrixAppendBeeperAIRunPartOptions extends MatrixBeeperAIRunPartOptions { + runId: string; +} +export interface MatrixFinishBeeperAIRunOptions { + finishReason?: string; + runId: string; + terminal?: { [key: string]: unknown }; + usage?: unknown /* agui.Usage */; +} +export interface MatrixErrorBeeperAIRunOptions { + message?: string; + runId: string; + terminal?: { [key: string]: unknown }; + type?: "error" | "abort"; +} +export interface MatrixDeleteBeeperAIRunOptions { + runId: string; +} +export interface MatrixBeeperAIRunSnapshot { + body: string; + events: Array<{ [key: string]: unknown }>; + initialAIMessage: { [key: string]: unknown }; + finalAIMessage: { [key: string]: unknown }; + metadata: { [key: string]: unknown }; + messageId: string; + runId: string; + threadId: string; +} +export interface MatrixStartBeeperAIRunStreamOptions extends MatrixBeginBeeperAIRunOptions { + initialEvents?: Array<{ [key: string]: unknown }>; + initialParts?: MatrixBeeperAIRunPartOptions[]; + roomId: string; + streamType?: string; + subscribers?: MatrixBeeperStreamSubscriber[]; + threadRootEventId?: string; + userId?: string; +} +export interface MatrixBeeperAIRunStreamResult extends MatrixBeeperAIRunSnapshot { + descriptor?: { [key: string]: unknown }; + eventId: string; + raw?: unknown; + replacementEventId?: string; + roomId: string; +} export interface MatrixCryptoStatus { deviceId?: string; hasRecoveryKey: boolean; @@ -289,9 +397,25 @@ export interface MatrixReactionOptions { export interface MatrixFetchRoomOptions { roomId: string; } +export interface MatrixFetchRoomPowerLevelsOptions { + roomId: string; +} +export interface MatrixRoomPowerLevels { + ban?: number /* float64 */; + events?: { [key: string]: number /* float64 */}; + eventsDefault?: number /* float64 */; + invite?: number /* float64 */; + kick?: number /* float64 */; + notifications?: { [key: string]: number /* float64 */}; + raw: { [key: string]: unknown}; + redact?: number /* float64 */; + stateDefault?: number /* float64 */; + users?: { [key: string]: number /* float64 */}; + usersDefault?: number /* float64 */; +} export interface MatrixRoomStateInput { content: { [key: string]: unknown }; - stateKey: string; + stateKey?: string; type: string; } export interface MatrixCreateRoomOptions { diff --git a/packages/pickle/src/index.ts b/packages/pickle/src/index.ts index 8201d32..c999242 100644 --- a/packages/pickle/src/index.ts +++ b/packages/pickle/src/index.ts @@ -1,5 +1,6 @@ export { copyBytes } from "./bytes"; export { createMatrixClient } from "./client"; +export { getMatrixWhoami } from "./auth"; export { onInvite, onMessage, onRawEvent, onReaction } from "./helpers"; export type { MatrixClient, @@ -34,7 +35,18 @@ export type { MatrixAppserviceRegistration, MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, + MatrixAppserviceSetProfileOptions, MatrixAppserviceUserOptions, + MatrixAppendBeeperAIRunEventOptions, + MatrixAppendBeeperAIRunPartOptions, + MatrixBeginBeeperAIRunOptions, + MatrixBeeperAIRunPartOptions, + MatrixBeeperAIRunSnapshot, + MatrixBeeperAIRunStreamResult, + MatrixDeleteBeeperAIRunOptions, + MatrixErrorBeeperAIRunOptions, + MatrixFinishBeeperAIRunOptions, + MatrixStartBeeperAIRunStreamOptions, } from "./runtime-types"; export type { ApplySyncResponseOptions, diff --git a/packages/pickle/src/runtime-types.ts b/packages/pickle/src/runtime-types.ts index 39bb3a2..79f85c7 100644 --- a/packages/pickle/src/runtime-types.ts +++ b/packages/pickle/src/runtime-types.ts @@ -24,13 +24,21 @@ export type { MatrixAppserviceRegistration, MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, + MatrixAppserviceSetProfileOptions, MatrixAppserviceUserOptions, + MatrixAppendBeeperAIRunEventOptions, + MatrixAppendBeeperAIRunPartOptions, MatrixApplySyncResponseOptions, MatrixBanUserOptions, + MatrixBeginBeeperAIRunOptions, + MatrixBeeperAIRunPartOptions, + MatrixBeeperAIRunSnapshot, + MatrixBeeperAIRunStreamResult, MatrixCoreInitOptions, MatrixCryptoStatus, MatrixCreateRoomOptions, MatrixCreateRoomResult, + MatrixDeleteBeeperAIRunOptions, MatrixDeleteMessageOptions, MatrixDownloadEncryptedMediaOptions, MatrixDownloadMediaOptions, @@ -39,6 +47,7 @@ export type { MatrixEditMessageOptions, MatrixEncryptedFile, MatrixEncryptedFileKey, + MatrixErrorBeeperAIRunOptions, MatrixFinalizeBeeperStreamMessageOptions, MatrixFinalizeBeeperStreamMessageResult, MatrixFetchMessageOptions, @@ -50,6 +59,7 @@ export type { MatrixFetchRoomStateEventOptions, MatrixFetchRoomStateOptions, MatrixFetchRoomStateResult, + MatrixFinishBeeperAIRunOptions, MatrixGetAccountDataOptions, MatrixGetRoomAccountDataOptions, MatrixGetUserOptions, @@ -99,6 +109,7 @@ export type { MatrixSetOwnDisplayNameOptions, MatrixSetAccountDataOptions, MatrixSetRoomAccountDataOptions, + MatrixStartBeeperAIRunStreamOptions, MatrixStartBeeperStreamMessageOptions, MatrixStartBeeperStreamMessageResult, MatrixSyncOnceOptions, diff --git a/packages/pickle/src/streams/beeper-message.ts b/packages/pickle/src/streams/beeper-message.ts index cf68be3..1c877b3 100644 --- a/packages/pickle/src/streams/beeper-message.ts +++ b/packages/pickle/src/streams/beeper-message.ts @@ -47,9 +47,9 @@ export function applyFinalMessagePart(state: BeeperFinalMessageAccumulator, part if (existing !== undefined) return existing; const index = state.message.parts.length; state.message.parts.push(stripUndefined({ + content: "", providerMetadata, state: "streaming", - text: "", type: kind, })); indexById.set(partId, index); @@ -71,19 +71,14 @@ export function applyFinalMessagePart(state: BeeperFinalMessageAccumulator, part const existing = state.toolIndexByCallId.get(toolCallId); if (existing !== undefined) return existing; const toolName = state.toolNameByCallId.get(toolCallId) ?? "tool"; - const dynamic = state.toolDynamicByCallId.get(toolCallId) ?? false; const index = state.message.parts.length; - state.message.parts.push(stripUndefined(dynamic ? { - input: undefined, - state: "input-streaming", - toolCallId, - toolName, - type: "dynamic-tool", - } : { - input: undefined, - state: "input-streaming", + state.message.parts.push(stripUndefined({ + arguments: "", + id: toolCallId, + name: toolName, + state: "awaiting-input", toolCallId, - type: `tool-${toolName}`, + type: "tool-call", })); state.toolIndexByCallId.set(toolCallId, index); return index; @@ -91,11 +86,8 @@ export function applyFinalMessagePart(state: BeeperFinalMessageAccumulator, part const updateToolLabel = (toolPart: Record) => { const toolName = toolCallId ? state.toolNameByCallId.get(toolCallId) : undefined; if (!toolName) return; - if (toolPart.type === "dynamic-tool" && (toolPart.toolName === undefined || toolPart.toolName === "tool")) { - toolPart.toolName = toolName; - } - if (toolPart.type === "tool-tool" || toolPart.type === "tool-") { - toolPart.type = `tool-${toolName}`; + if (toolPart.type === "tool-call" && (toolPart.name === undefined || toolPart.name === "tool")) { + toolPart.name = toolName; } }; @@ -113,7 +105,7 @@ export function applyFinalMessagePart(state: BeeperFinalMessageAccumulator, part case "text-delta": { if (!id || typeof part.delta !== "string") return; const textPart = getPart(ensureStreamingPart("text", state.textIndexById, id)); - textPart.text = `${typeof textPart.text === "string" ? textPart.text : ""}${part.delta}`; + textPart.content = `${typeof textPart.content === "string" ? textPart.content : ""}${part.delta}`; textPart.state = "streaming"; return; } @@ -130,7 +122,7 @@ export function applyFinalMessagePart(state: BeeperFinalMessageAccumulator, part case "reasoning-delta": { if (!id || typeof part.delta !== "string") return; const reasoningPart = getPart(ensureStreamingPart("reasoning", state.reasoningIndexById, id)); - reasoningPart.text = `${typeof reasoningPart.text === "string" ? reasoningPart.text : ""}${part.delta}`; + reasoningPart.content = `${typeof reasoningPart.content === "string" ? reasoningPart.content : ""}${part.delta}`; reasoningPart.state = "streaming"; return; } @@ -172,6 +164,7 @@ export function applyFinalMessagePart(state: BeeperFinalMessageAccumulator, part const toolPart = getPart(index); updateToolLabel(toolPart); toolPart.state = "input-streaming"; + toolPart.arguments = next; toolPart.input = parsePartialJson(next); return; } @@ -181,7 +174,7 @@ export function applyFinalMessagePart(state: BeeperFinalMessageAccumulator, part if (index === undefined) return; const toolPart = getPart(index); updateToolLabel(toolPart); - toolPart.state = type === "tool-input-error" ? "output-error" : "input-available"; + toolPart.state = type === "tool-input-error" ? "output-error" : "input-complete"; toolPart.input = part.input; toolPart.providerExecuted = part.providerExecuted; toolPart.callProviderMetadata = part.providerMetadata; @@ -259,27 +252,27 @@ export function finalizeAccumulatedAIMessage(state: BeeperFinalMessageAccumulato export function getFinalMessageText(message: Record): string { const parts = Array.isArray(message.parts) ? message.parts : []; return parts - .filter((part): part is Record => isRecord(part) && part.type === "text" && typeof part.text === "string") - .map((part) => part.text) + .filter((part): part is Record => isRecord(part) && part.type === "text" && (typeof part.content === "string" || typeof part.text === "string")) + .map((part) => typeof part.content === "string" ? part.content : part.text) .join(""); } export function compactFinalContent(options: { aiMessage: Record; body: string }): { aiMessage: Record; body: string } { if (eventContentBytes(options.aiMessage, options.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) return options; - const compact = compactAIMessage(options.aiMessage, { keepToolInput: true, textBudgetChars: Infinity }); + const compact = compactAIMessage(options.aiMessage, { keepToolInput: true, keepToolOutput: true, textBudgetChars: Infinity }); if (eventContentBytes(compact, options.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) return { aiMessage: compact, body: options.body }; - const noToolInput = compactAIMessage(options.aiMessage, { keepToolInput: false, textBudgetChars: Infinity }); - if (eventContentBytes(noToolInput, options.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) return { aiMessage: noToolInput, body: options.body }; + const noToolPayloads = compactAIMessage(options.aiMessage, { keepToolInput: true, keepToolOutput: false, textBudgetChars: Infinity }); + if (eventContentBytes(noToolPayloads, options.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) return { aiMessage: noToolPayloads, body: options.body }; - const totalTextChars = options.body.length + messageTextChars(noToolInput); + const totalTextChars = options.body.length + messageTextChars(noToolPayloads); let low = 0; let high = totalTextChars; - let best = compactTextContent(noToolInput, options.body, 0); + let best = compactTextContent(noToolPayloads, options.body, 0); while (low <= high) { const mid = Math.floor((low + high) / 2); - const candidate = compactTextContent(noToolInput, options.body, mid); + const candidate = compactTextContent(noToolPayloads, options.body, mid); if (eventContentBytes(candidate.aiMessage, candidate.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) { best = candidate; low = mid + 1; @@ -307,14 +300,14 @@ export function eventContentBytes(aiMessage: Record, body: stri function compactTextContent(aiMessage: Record, body: string, textBudgetChars: number): { aiMessage: Record; body: string } { const budget = { remaining: textBudgetChars }; return { - aiMessage: compactAIMessage(aiMessage, { budget, keepToolInput: false }), + aiMessage: compactAIMessage(aiMessage, { budget, keepToolInput: true, keepToolOutput: false }), body: takeText(body, budget), }; } function compactAIMessage( message: Record, - options: { budget?: { remaining: number }; keepToolInput: boolean; textBudgetChars?: number }, + options: { budget?: { remaining: number }; keepToolInput: boolean; keepToolOutput: boolean; textBudgetChars?: number }, ): Record { const budget = options.budget ?? ( options.textBudgetChars === Infinity ? undefined : { remaining: options.textBudgetChars ?? Infinity } @@ -324,6 +317,7 @@ function compactAIMessage( metadata: compactMetadata(isRecord(message.metadata) ? message.metadata : {}), parts: compactParts(Array.isArray(message.parts) ? message.parts : [], { keepToolInput: options.keepToolInput, + keepToolOutput: options.keepToolOutput, ...(budget ? { budget } : {}), }), role: message.role, @@ -342,20 +336,27 @@ function compactMetadata(metadata: Record): Record[] { +function compactParts(parts: unknown[], options: { budget?: { remaining: number }; keepToolInput: boolean; keepToolOutput: boolean }): Record[] { return parts .filter(isRecord) .flatMap((part) => { if (part.type === "text" || part.type === "reasoning") { + const content = typeof part.content === "string" ? part.content : typeof part.text === "string" ? part.text : undefined; return [stripUndefined({ state: part.state, - text: typeof part.text === "string" ? takeText(part.text, options.budget) : part.text, + ...(typeof part.text === "string" + ? { text: typeof content === "string" ? takeText(content, options.budget) : content } + : { content: typeof content === "string" ? takeText(content, options.budget) : content }), type: part.type, })]; } - if (part.type === "dynamic-tool" || (typeof part.type === "string" && part.type.startsWith("tool-"))) { + if (part.type === "tool-call" || part.type === "dynamic-tool" || (typeof part.type === "string" && part.type.startsWith("tool-"))) { return [stripUndefined({ + arguments: part.arguments, + id: part.id, input: options.keepToolInput ? part.input : undefined, + name: part.name, + output: options.keepToolOutput ? part.output : undefined, state: part.state, toolCallId: part.toolCallId, toolName: part.toolName, @@ -389,8 +390,9 @@ function truncateWithNotice(value: string, maxChars: number): string { function messageTextChars(message: Record): number { const parts = Array.isArray(message.parts) ? message.parts : []; return parts.reduce((total, part) => { - if (!isRecord(part) || typeof part.text !== "string") return total; - return total + part.text.length; + if (!isRecord(part)) return total; + const text = typeof part.content === "string" ? part.content : typeof part.text === "string" ? part.text : ""; + return total + text.length; }, 0); } diff --git a/packages/pickle/src/types.ts b/packages/pickle/src/types.ts index 7ce12da..f70cb07 100644 --- a/packages/pickle/src/types.ts +++ b/packages/pickle/src/types.ts @@ -1,4 +1,4 @@ -import type { MatrixAppserviceInitOptions } from "./generated-runtime-types"; +import type { MatrixAppserviceInitOptions, MatrixRoomPowerLevels } from "./generated-runtime-types"; export interface MatrixStore { delete(key: string): Promise; @@ -561,19 +561,7 @@ export interface RoomStateEvent { type: string; } -export interface RoomPowerLevels { - ban?: number; - events?: Record; - eventsDefault?: number; - invite?: number; - kick?: number; - notifications?: Record; - redact?: number; - raw: Record; - stateDefault?: number; - users?: Record; - usersDefault?: number; -} +export type RoomPowerLevels = MatrixRoomPowerLevels; export interface FetchRoomPowerLevelsOptions { roomId: string; diff --git a/packages/state-file/package.json b/packages/state-file/package.json index f2a5f08..b659247 100644 --- a/packages/state-file/package.json +++ b/packages/state-file/package.json @@ -2,6 +2,12 @@ "name": "@beeper/pickle-state-file", "version": "0.1.0", "description": "Filesystem Matrix state adapter for Pickle", + "repository": { + "type": "git", + "url": "git+https://github.com/beeper/pickle.git", + "directory": "packages/state-file" + }, + "license": "MPL-2.0", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/state-file/src/index.test.ts b/packages/state-file/src/index.test.ts index 9e3758d..1ae4c71 100644 --- a/packages/state-file/src/index.test.ts +++ b/packages/state-file/src/index.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -26,4 +26,17 @@ describe("FileMatrixStore", () => { await rm(dir, { force: true, recursive: true }); } }); + + it("treats an empty index as an empty store", async () => { + const dir = await mkdtemp(join(tmpdir(), "matrix-store-empty-index-")); + try { + await writeFile(join(dir, "index.json"), ""); + const store = createFileMatrixStore(dir); + + expect(await store.get("crypto/account")).toBeNull(); + expect(await store.list("crypto/")).toEqual([]); + } finally { + await rm(dir, { force: true, recursive: true }); + } + }); }); diff --git a/packages/state-file/src/index.ts b/packages/state-file/src/index.ts index b9f04ff..e9628de 100644 --- a/packages/state-file/src/index.ts +++ b/packages/state-file/src/index.ts @@ -1,5 +1,5 @@ -import { createHash } from "node:crypto"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { copyBytes, type MatrixStore } from "@beeper/pickle"; @@ -58,6 +58,10 @@ export class FileMatrixStore implements MatrixStore { } try { const raw = await readFile(join(this.#dir, "index.json"), "utf8"); + if (!raw.trim()) { + this.#index = new Map(); + return this.#index; + } this.#index = new Map(Object.entries(JSON.parse(raw) as Record)); } catch (error) { if (!isNodeENOENT(error)) { @@ -70,10 +74,13 @@ export class FileMatrixStore implements MatrixStore { async #saveIndex(index: Map): Promise { await mkdir(this.#dir, { recursive: true }); + const path = join(this.#dir, "index.json"); + const tmp = join(this.#dir, `index.json.${process.pid}.${randomUUID()}.tmp`); await writeFile( - join(this.#dir, "index.json"), + tmp, JSON.stringify(Object.fromEntries(index), null, 2) ); + await rename(tmp, path); } } diff --git a/packages/state-simple/package.json b/packages/state-simple/package.json index 39e3203..af414ec 100644 --- a/packages/state-simple/package.json +++ b/packages/state-simple/package.json @@ -2,6 +2,12 @@ "name": "@beeper/pickle-state-simple", "version": "0.1.0", "description": "Simple getter/setter Matrix state adapter for Pickle", + "repository": { + "type": "git", + "url": "git+https://github.com/beeper/pickle.git", + "directory": "packages/state-simple" + }, + "license": "MPL-2.0", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/state-sqlite/package.json b/packages/state-sqlite/package.json index 0f092da..df248f0 100644 --- a/packages/state-sqlite/package.json +++ b/packages/state-sqlite/package.json @@ -2,6 +2,12 @@ "name": "@beeper/pickle-state-sqlite", "version": "0.1.0", "description": "SQLite Matrix state adapter for Pickle", + "repository": { + "type": "git", + "url": "git+https://github.com/beeper/pickle.git", + "directory": "packages/state-sqlite" + }, + "license": "MPL-2.0", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a3e6cc..6ca4f7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) bots/dummybot: dependencies: @@ -65,7 +65,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) examples/beeper-streaming-smoke: dependencies: @@ -137,7 +137,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) packages/bridge: dependencies: @@ -168,7 +168,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) packages/chat-adapter: dependencies: @@ -196,7 +196,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) packages/cloudflare: dependencies: @@ -215,7 +215,37 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) + + packages/openclaw: + devDependencies: + '@beeper/pickle-ag-ui': + specifier: workspace:^ + version: link:../ag-ui + '@beeper/pickle-bridge': + specifier: workspace:^ + version: link:../bridge + '@beeper/pickle-state-file': + specifier: workspace:^ + version: link:../state-file + '@types/node': + specifier: ^20.0.0 + version: 20.19.39 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.1.5(vitest@4.1.5) + openclaw: + specifier: 2026.5.28 + version: 2026.5.28 + tsdown: + specifier: ^0.21.10 + version: 0.21.10(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) packages/pi: dependencies: @@ -246,7 +276,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) packages/pickle: devDependencies: @@ -261,7 +291,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) packages/state-file: dependencies: @@ -283,7 +313,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) packages/state-indexeddb: dependencies: @@ -327,7 +357,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) packages/state-simple: dependencies: @@ -349,7 +379,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) packages/state-sqlite: dependencies: @@ -371,13 +401,27 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) packages: '@ag-ui/core@0.0.52': resolution: {integrity: sha512-Xo0bUaNV56EqylzcrAuhUkQX7et7+SZIrqZZtEByGwEq/I1EHny6ZMkWHLkKR7UNi0FJZwJyhKYmKJS3B2SEgA==} + '@agentclientprotocol/sdk@0.22.1': + resolution: {integrity: sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + '@anthropic-ai/sdk@0.98.0': + resolution: {integrity: sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@babel/generator@8.0.0-rc.3': resolution: {integrity: sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -424,6 +468,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} @@ -485,6 +532,14 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@clack/core@1.3.1': + resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.4.0': + resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} + engines: {node: '>= 20.12.0'} + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} @@ -532,6 +587,10 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@earendil-works/pi-tui@0.76.0': + resolution: {integrity: sha512-TWQEWqc38gVRYr/VTrlfePQPpJk938gNNLL1xuv0M+9cTkVr880/Q3baZQrxKoLrmN/MmVx7TyR8knYZGxTqqg==} + engines: {node: '>=22.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -853,6 +912,40 @@ packages: cpu: [x64] os: [win32] + '@google/genai@2.6.0': + resolution: {integrity: sha512-HjoW3mPuEn7pnuKABJl9VbDoWDSF4nbwYKYvYYor7YjPeDxrrBxHzu2d1Prcd+BAuC4w+85UP6y7ZdcrQAoO7g==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@grammyjs/runner@2.0.3': + resolution: {integrity: sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A==} + engines: {node: '>=12.20.0 || >=14.13.1'} + peerDependencies: + grammy: ^1.13.1 + + '@grammyjs/transformer-throttler@1.2.1': + resolution: {integrity: sha512-CpWB0F3rJdUiKsq7826QhQsxbZi4wqfz1ccKX+fr+AOC+o8K7ZvS+wqX0suSu1QCsyUq2MDpNiKhyL2ZOJUS4w==} + engines: {node: ^12.20.0 || >=14.13.1} + peerDependencies: + grammy: ^1.0.0 + + '@grammyjs/types@3.27.3': + resolution: {integrity: sha512-yUKMLliGsGbnxu96YUJ7km7B0zy4PzeH/Jvti5705R/LeKDMqkDV4DckMSt+OrliWQpTwQljHE0QLol5zgxBkg==} + + '@homebridge/ciao@1.3.9': + resolution: {integrity: sha512-TMy9zy173jDOpnFXDqL3BPIQn5lfcAkSsivYQatCCakoHk4fLGd7QjfAaNGYE3Ox+/ZI6Lq0e1gGcz1qdw/IbA==} + hasBin: true + + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -999,6 +1092,10 @@ packages: '@types/node': optional: true + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1015,12 +1112,62 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lydell/node-pty-darwin-arm64@1.2.0-beta.12': + resolution: {integrity: sha512-tqaifcY9Cr41SblO1+FLzh8oxxtkNhuW9Dhl22lKme9BreYvKvxEZcdPIXTuqkJc5tagOEC4QHShKmJjLyLXLQ==} + cpu: [arm64] + os: [darwin] + + '@lydell/node-pty-darwin-x64@1.2.0-beta.12': + resolution: {integrity: sha512-4LrS5pCJwqHKDVf1zS2gyNV0m4hKAXch+XZNhbZ6LY8uwVL8BhchzQBO40Os5anuRxRCWzHpw4Sp64Ie8q7E4Q==} + cpu: [x64] + os: [darwin] + + '@lydell/node-pty-linux-arm64@1.2.0-beta.12': + resolution: {integrity: sha512-Sx+A71x5BDGHt9ansfrtGxwq2VFVDWvJUAdlUL0Hv0qeiJUfts+hgopx+CgT4PSwahKjdEgtu0+FAfY9rICKRw==} + cpu: [arm64] + os: [linux] + + '@lydell/node-pty-linux-x64@1.2.0-beta.12': + resolution: {integrity: sha512-bJzs94njofYhGg/UDqW1nj0dtvvu+2OvxMY+RlLS1T17VgcktKoIR6PuenTwE5HJ/D6StCPADmXcT0nNsCKmIQ==} + cpu: [x64] + os: [linux] + + '@lydell/node-pty-win32-arm64@1.2.0-beta.12': + resolution: {integrity: sha512-p7POgjVEiFaBC3/y+AKuV1FzePCsJ6HmZDv2XK+jBZSfwP8+uBAw181ZiKYN1YuRa/XpmBGaWezcI8hZkbW++g==} + cpu: [arm64] + os: [win32] + + '@lydell/node-pty-win32-x64@1.2.0-beta.12': + resolution: {integrity: sha512-IDFa00g7qUDGUYgByrUBJtC+mOjYVt/8KYyWivCg5JjGOHbBUACUQZLl0jTWmnr+tld/UyTpX90a2PY6oTVtRw==} + cpu: [x64] + os: [win32] + + '@lydell/node-pty@1.2.0-beta.12': + resolution: {integrity: sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@mistralai/mistralai@2.2.5': + resolution: {integrity: sha512-ATbWzKkNzNAZ+gtw9MI/c/ULTMG80tKUiRNIbQFfg4OP0uEZZpTfXZeBCNfs5Dq0uqMQ/tQWc4o6RRJQtMrpDA==} + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@mozilla/readability@0.6.0': + resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} + engines: {node: '>=14.0.0'} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -1039,6 +1186,16 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@openclaw/fs-safe@0.3.0': + resolution: {integrity: sha512-uIBE441CIt1kIURoP9qRGKZ8LkGyfD9ZzeESjwAd29ZPWtghws/5GR3Pjb67jKdcJHP1I6roNXcvnhzAU7lHlA==} + engines: {node: '>=20.11'} + + '@openclaw/proxyline@0.3.3': + resolution: {integrity: sha512-sftHnW69NHQqLjCxBTvQ8f/eQl+peZ5pHCBQtuTWBbeuYRHZ0/GXVTmw/O/YKsShMbqPWhJB0UYtPPdvCUSS8w==} + engines: {node: '>=22.19.0'} + peerDependencies: + undici: '>=8.3.0 <9' + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -1055,6 +1212,36 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.2': + resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -1150,6 +1337,9 @@ packages: '@rolldown/pluginutils@1.0.0-rc.17': resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + '@silvia-odwyer/photon-node@0.3.4': + resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -1157,6 +1347,9 @@ packages: '@speed-highlight/core@1.2.15': resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1182,6 +1375,13 @@ packages: engines: {node: '>=18'} hasBin: true + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1218,6 +1418,9 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/seedrandom@3.0.8': resolution: {integrity: sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==} @@ -1268,6 +1471,29 @@ packages: '@workflow/serde@4.1.0-beta.2': resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1276,6 +1502,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -1290,6 +1520,9 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1304,27 +1537,73 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + bn.js@4.12.3: + resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bottleneck@2.19.5: + resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1332,6 +1611,10 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} @@ -1341,13 +1624,74 @@ packages: chat@4.26.0: resolution: {integrity: sha512-QToDnIEGpyb8yQA6YLMHOSRK30YVk4RtsyFyuWFYyB2c4jQlyIrSWtwVK7qyvmvqzQp9uDwCdJRAhS8GtCHAGQ==} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + clawpdf@0.2.0: + resolution: {integrity: sha512-Za4HD3CMRHNqOXOOyVJiQLnEuezRZR/oXiBzraTwL5XEQZuBwFxnyC1UzN4AjQWV2JrLN3ItbzfPRGE0gGOVwg==} + engines: {node: '>=20'} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + croner@10.0.1: + resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} + engines: {node: '>=18.0'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1359,6 +1703,13 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} @@ -1371,12 +1722,20 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1392,6 +1751,13 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1426,10 +1792,27 @@ packages: oxc-resolver: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -1438,12 +1821,28 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -1454,6 +1853,13 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -1466,10 +1872,36 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1480,10 +1912,28 @@ packages: resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} engines: {node: '>=18'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1496,14 +1946,38 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + file-type@22.0.1: + resolution: {integrity: sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==} + engines: {node: '>=22'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -1517,6 +1991,33 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} @@ -1524,27 +2025,85 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + google-auth-library@10.6.2: + resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grammy@1.43.0: + resolution: {integrity: sha512-7dYm06A945mXuIk/5HUlSjeyIYChW8vCEiU2dkOKKqJJzwAWxTkCc91Eqbz7TgODh2rtFFKWI/fekowWHOkmjQ==} + engines: {node: ^12.20.0 || >=14.13.1} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + hono@4.12.23: + resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} + engines: {node: '>=16.9.0'} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} + engines: {node: ^20.17.0 || >=22.9.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-id@4.1.3: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true @@ -1553,18 +2112,47 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-without-cache@0.3.3: resolution: {integrity: sha512-bDxwDdF04gm550DfZHgffvlX+9kUlcz32UD0AeBTmVPFiWkrexF2XVmiuFFbDhiFuP8fQkrkvI2KdSNPYWAXkQ==} engines: {node: '>=20.19.0'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1577,6 +2165,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -1585,6 +2176,9 @@ packages: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1600,6 +2194,13 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1616,13 +2217,47 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + kysely@0.29.2: + resolution: {integrity: sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==} + engines: {node: '>=22.0.0'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1693,6 +2328,18 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + linkedom@0.18.12: + resolution: {integrity: sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + + linkify-it@5.0.1: + resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -1700,9 +2347,16 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@11.5.0: + resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1713,14 +2367,27 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + markdown-it@14.2.0: + resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} + hasBin: true + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + marked@18.0.2: resolution: {integrity: sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==} engines: {node: '>= 20'} hasBin: true + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -1754,6 +2421,17 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1846,11 +2524,37 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - miniflare@4.20260430.0: + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + miniflare@4.20260430.0: resolution: {integrity: sha512-MWvMm3Siho9Yj7lbJZidLs8hbrRvIcOrif2mnsHQZdvoKfedpea+GaN8XJxbpRcq0B2WzNI1BB1ihdnqes3/ZA==} engines: {node: '>=22.0.0'} hasBin: true + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -1868,6 +2572,23 @@ packages: engines: {node: ^18 || >=20} hasBin: true + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-addon-api@8.8.0: + resolution: {integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==} + engines: {node: ^18 || ^20 || >= 21} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-edge-tts@1.2.10: + resolution: {integrity: sha512-bV2i4XU54D45+US0Zm1HcJRkifuB3W438dWyuJEHLQdKxnuqlI1kim2MOvR6Q3XUQZvfF9PoDyR1Rt7aeXhPdQ==} + hasBin: true + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -1877,15 +2598,55 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-html-parser@7.1.0: resolution: {integrity: sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==} nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + openai@6.39.0: + resolution: {integrity: sha512-O61LIsimY3acVabwvomwFhwrnN36yvHY2quIfy9keEcFytGgWeV35yLHQ6NVMLSBxRpHmcg2yuhCnlu2HT4pLQ==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + openclaw@2026.5.28: + resolution: {integrity: sha512-p7jGN9wzCrqEvHNI6Y7+eh6DWoYDzJ1iQGKTm8xqQ2uQ9/2mY1CCf87WoZeb0+m3eHKSGchlI3tN33fE1lMtEA==} + engines: {node: '>=22.19.0'} + hasBin: true + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -1905,6 +2666,10 @@ packages: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -1912,6 +2677,13 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} @@ -1923,9 +2695,16 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -1948,6 +2727,19 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.5.10: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} @@ -1957,6 +2749,33 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + protobufjs@7.6.1: + resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==} + engines: {node: '>=12.0.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -1966,10 +2785,32 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quickjs-wasi@3.0.0: + resolution: {integrity: sha512-X7ouKC4ZVf9bXQ8rsE7+L6TeBbesejAJH61x16xRaGAQGfBHHRcniWgzJZZVtHc8rS9yVsY+Tvk8/usAosg4bg==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + rastermill@0.3.0: + resolution: {integrity: sha512-4g2i0I7M5sba//lFBh19Wi0hDGw8o+isnt/BtEyqQXIZaYclhcNBwL/Fw/6gDCp7aaLwQHADuUvyHCB0Oat5Vw==} + engines: {node: '>=20'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -1982,6 +2823,17 @@ packages: remend@1.3.0: resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -1989,6 +2841,14 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2017,9 +2877,19 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2031,6 +2901,23 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2043,13 +2930,35 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -2058,18 +2967,67 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sqlite-vec-darwin-arm64@0.1.9: + resolution: {integrity: sha512-jSsZpE42OfBkGL/ItyJTVCUwl6o6Ka3U5rc4j+UBDIQzC1ulSSKMEhQLthsOnF/MdAf1MuAkYhkdKmmcjaIZQg==} + cpu: [arm64] + os: [darwin] + + sqlite-vec-darwin-x64@0.1.9: + resolution: {integrity: sha512-KDlVyqQT7pnOhU1ymB9gs7dMbSoVmKHitT+k1/xkjarcX8bBqPxWrGlK/R+C5WmWkfvWwyq5FfXfiBYCBs6PlA==} + cpu: [x64] + os: [darwin] + + sqlite-vec-linux-arm64@0.1.9: + resolution: {integrity: sha512-5wXVJ9c9kR4CHm/wVqXb/R+XUHTdpZ4nWbPHlS+gc9qQFVHs92Km4bPnCKX4rtcPMzvNis+SIzMJR1SCEwpuUw==} + cpu: [arm64] + os: [linux] + + sqlite-vec-linux-x64@0.1.9: + resolution: {integrity: sha512-w3tCH8xK2finW8fQJ/m8uqKodXUZ9KAuAar2UIhz4BHILfpE0WM/MTGCRfa7RjYbrYim5Luk3guvMOGI7T7JQA==} + cpu: [x64] + os: [linux] + + sqlite-vec-windows-x64@0.1.9: + resolution: {integrity: sha512-y3gEIyy/17bq2QFPQOWLE68TYWcRZkBQVA2XLrTPHNTOp55xJi/BBBmOm40tVMDMjtP+Elpk6UBUXdaq+46b0Q==} + cpu: [x64] + os: [win32] + + sqlite-vec@0.1.9: + resolution: {integrity: sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -2078,6 +3036,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -2086,6 +3048,14 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tar@7.5.13: + resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} + engines: {node: '>=18'} + + tar@7.5.15: + resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} + engines: {node: '>=18'} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -2109,6 +3079,14 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -2116,9 +3094,20 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + tree-sitter-bash@0.25.1: + resolution: {integrity: sha512-7hMytuYIMoXOq24yRulgIxthE9YmggZIOHCyPTTuJcu6EU54tYD+4G39cUb28kxC6jMf/AbPfWGLQtgPTdh3xw==} + peerDependencies: + tree-sitter: ^0.25.0 + peerDependenciesMeta: + tree-sitter: + optional: true + trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + tsdown@0.21.10: resolution: {integrity: sha512-3wk73yBhZe/wX7REqSdivNQ84TDs1mJ+IlnzrrEREP70xlJ/AEIzqaI04l/TzMKVIdkTdC3CPaADn2Lk/0SkdA==} engines: {node: '>=20.19.0'} @@ -2150,11 +3139,37 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tslog@4.10.2: + resolution: {integrity: sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g==} + engines: {node: '>=16'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typebox@1.1.38: + resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} @@ -2168,6 +3183,10 @@ packages: resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} engines: {node: '>=20.18.1'} + undici@8.3.0: + resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} + engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -2190,6 +3209,10 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unrun@0.2.37: resolution: {integrity: sha512-AA7vDuYsgeSYVzJMm16UKA+aXFKhy7nFqW9z5l7q44K4ppFWZAMqYS58ePRZbugMLPH0fwwMzD5A8nP0avxwZQ==} engines: {node: '>=20.19.0'} @@ -2200,6 +3223,13 @@ packages: synckit: optional: true + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -2290,12 +3320,27 @@ packages: jsdom: optional: true + web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + web-tree-sitter@0.26.9: + resolution: {integrity: sha512-YJwSHANl6XFgeEjB8nitgj0qZYt5gkIesJ4w2srS2wcLB4GUa4xcOkM0YaMsU6WNR53YVIkDSY7Ej4pf3IXtCA==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2321,6 +3366,17 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -2333,15 +3389,67 @@ packages: utf-8-validate: optional: true + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -2351,6 +3459,17 @@ snapshots: dependencies: zod: 3.25.76 + '@agentclientprotocol/sdk@0.22.1(zod@4.4.3)': + dependencies: + zod: 4.4.3 + + '@anthropic-ai/sdk@0.98.0(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 4.4.3 + '@babel/generator@8.0.0-rc.3': dependencies: '@babel/parser': 8.0.0-rc.3 @@ -2390,6 +3509,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@borewit/text-codec@0.2.2': {} + '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -2548,6 +3669,18 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@clack/core@1.3.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.4.0': + dependencies: + '@clack/core': 1.3.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260430.1)': @@ -2575,6 +3708,11 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@earendil-works/pi-tui@0.76.0': + dependencies: + get-east-asian-width: 1.6.0 + marked: 15.0.12 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2747,6 +3885,44 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@google/genai@2.6.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.6.1 + ws: 8.21.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@grammyjs/runner@2.0.3(grammy@1.43.0)': + dependencies: + abort-controller: 3.0.0 + grammy: 1.43.0 + + '@grammyjs/transformer-throttler@1.2.1(grammy@1.43.0)': + dependencies: + bottleneck: 2.19.5 + grammy: 1.43.0 + + '@grammyjs/types@3.27.3': {} + + '@homebridge/ciao@1.3.9': + dependencies: + debug: 4.4.3 + fast-deep-equal: 3.1.3 + source-map-support: 0.5.21 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@hono/node-server@1.19.14(hono@4.12.23)': + dependencies: + hono: 4.12.23 + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -2850,6 +4026,10 @@ snapshots: optionalDependencies: '@types/node': 20.19.39 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2869,6 +4049,33 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lydell/node-pty-darwin-arm64@1.2.0-beta.12': + optional: true + + '@lydell/node-pty-darwin-x64@1.2.0-beta.12': + optional: true + + '@lydell/node-pty-linux-arm64@1.2.0-beta.12': + optional: true + + '@lydell/node-pty-linux-x64@1.2.0-beta.12': + optional: true + + '@lydell/node-pty-win32-arm64@1.2.0-beta.12': + optional: true + + '@lydell/node-pty-win32-x64@1.2.0-beta.12': + optional: true + + '@lydell/node-pty@1.2.0-beta.12': + optionalDependencies: + '@lydell/node-pty-darwin-arm64': 1.2.0-beta.12 + '@lydell/node-pty-darwin-x64': 1.2.0-beta.12 + '@lydell/node-pty-linux-arm64': 1.2.0-beta.12 + '@lydell/node-pty-linux-x64': 1.2.0-beta.12 + '@lydell/node-pty-win32-arm64': 1.2.0-beta.12 + '@lydell/node-pty-win32-x64': 1.2.0-beta.12 + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.29.2 @@ -2885,6 +4092,39 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@mistralai/mistralai@2.2.5': + dependencies: + ws: 8.21.0 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.23) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.23 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + '@mozilla/readability@0.6.0': {} + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2904,6 +4144,15 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@openclaw/fs-safe@0.3.0': + optionalDependencies: + jszip: 3.10.1 + tar: 7.5.13 + + '@openclaw/proxyline@0.3.3(undici@8.3.0)': + dependencies: + undici: 8.3.0 + '@opentelemetry/api@1.9.0': optional: true @@ -2921,6 +4170,28 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -2976,10 +4247,14 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.17': {} + '@silvia-odwyer/photon-node@0.3.4': {} + '@sindresorhus/is@7.2.0': {} '@speed-highlight/core@1.2.15': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@tanstack/ai-client@0.10.0(@opentelemetry/api@1.9.0)': @@ -3004,6 +4279,15 @@ snapshots: '@tanstack/devtools-event-client@0.4.3': {} + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -3044,6 +4328,8 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/retry@0.12.0': {} + '@types/seedrandom@3.0.8': {} '@types/unist@3.0.3': {} @@ -3064,7 +4350,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/expect@4.1.5': dependencies: @@ -3075,29 +4361,29 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7))': + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7) + vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) - '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7))': + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@22.19.17)(esbuild@0.27.7) + vite: 8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) - '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7))': + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.5': dependencies: @@ -3125,10 +4411,36 @@ snapshots: '@workflow/serde@4.1.0-beta.2': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansis@4.2.0: {} argparse@1.0.10: @@ -3139,6 +4451,13 @@ snapshots: array-union@2.1.0: {} + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.3 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-kit@3.0.0-beta.1: @@ -3155,26 +4474,74 @@ snapshots: bail@2.0.2: {} + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 + bignumber.js@9.3.1: {} + birpc@4.0.0: {} blake3-wasm@2.1.5: {} + bn.js@4.12.3: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} + bottleneck@2.19.5: {} + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + bytes@3.1.2: {} + cac@7.0.0: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase@5.3.1: {} + ccount@2.0.1: {} chai@6.2.2: {} + chalk@5.6.2: {} + character-entities@2.0.2: {} chardet@2.1.1: {} @@ -3191,10 +4558,57 @@ snapshots: transitivePeerDependencies: - supports-color + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chownr@3.0.0: {} + + clawpdf@0.2.0: {} + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@14.0.3: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cookie@1.1.1: {} + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + croner@10.0.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3211,18 +4625,26 @@ snapshots: css-what@6.2.2: {} + cssom@0.5.0: {} + + data-uri-to-buffer@4.0.1: {} + dataloader@1.4.0: {} debug@4.4.3: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 defu@6.1.7: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-indent@6.1.0: {} @@ -3233,6 +4655,10 @@ snapshots: dependencies: dequal: 2.0.3 + diff@9.0.0: {} + + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -3261,8 +4687,24 @@ snapshots: dts-resolver@2.1.3: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + emoji-regex@8.0.0: {} + empathic@2.0.0: {} + encodeurl@2.0.0: {} + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -3270,10 +4712,20 @@ snapshots: entities@4.5.0: {} + entities@7.0.1: {} + error-stack-parser-es@1.0.5: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -3333,6 +4785,10 @@ snapshots: '@esbuild/win32-x64': 0.27.7 optional: true + escalade@3.2.0: {} + + escape-html@1.0.3: {} + escape-string-regexp@5.0.0: {} esprima@4.0.1: {} @@ -3341,14 +4797,64 @@ snapshots: dependencies: '@types/estree': 1.0.8 + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + eventsource-parser@3.0.8: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.8 + expect-type@1.3.0: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend@3.0.2: {} extendable-error@0.1.7: {} fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3357,6 +4863,20 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-sha256@1.3.0: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.2: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3365,15 +4885,48 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + file-type@22.0.1: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 path-exists: 4.0.0 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -3389,6 +4942,46 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -3397,6 +4990,12 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + globby@11.1.0: dependencies: array-union: 2.1.0 @@ -3406,28 +5005,109 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + google-auth-library@10.6.2: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.4 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + grammy@1.43.0: + dependencies: + '@grammyjs/types': 3.27.3 + abort-controller: 3.0.0 + debug: 4.4.3 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + - supports-color + has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + he@1.2.0: {} + highlight.js@11.11.1: {} + + hono@4.12.23: {} + hookable@6.1.1: {} + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.5.0 + html-escaper@2.0.2: {} + html-escaper@3.0.3: {} + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http_ece@1.2.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-id@4.1.3: {} iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} + ignore@7.0.5: {} + + immediate@3.0.6: {} + import-without-cache@0.3.3: {} + inherits@2.0.4: {} + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + + ipaddr.js@2.4.0: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -3436,12 +5116,16 @@ snapshots: is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 is-windows@1.0.2: {} + isarray@1.0.0: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -3457,6 +5141,10 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jiti@2.7.0: {} + + jose@6.2.3: {} + js-tokens@10.0.0: {} js-yaml@3.14.2: @@ -3470,12 +5158,51 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json5@2.2.3: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + kleur@4.1.5: {} + kysely@0.29.2: {} + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.32.0: optional: true @@ -3525,14 +5252,30 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + linkedom@0.18.12: + dependencies: + css-select: 5.2.2 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.1.0 + uhyphen: 0.2.0 + + linkify-it@5.0.1: + dependencies: + uc.micro: 2.1.0 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 lodash.startcase@4.4.0: {} + long@5.3.2: {} + longest-streak@3.1.0: {} + lru-cache@11.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3547,10 +5290,23 @@ snapshots: dependencies: semver: 7.7.4 + markdown-it@14.2.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.1 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-table@3.0.4: {} + marked@15.0.12: {} + marked@18.0.2: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -3653,6 +5409,12 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdurl@2.0.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} micromark-core-commonmark@2.0.3: @@ -3851,6 +5613,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + miniflare@4.20260430.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -3863,6 +5631,20 @@ snapshots: - bufferutil - utf-8-validate + minimalistic-assert@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + mri@1.2.0: {} ms@2.1.3: {} @@ -3871,10 +5653,34 @@ snapshots: nanoid@5.1.11: {} + negotiator@1.0.0: {} + + node-addon-api@8.8.0: {} + + node-domexception@1.0.0: {} + + node-edge-tts@1.2.10: + dependencies: + https-proxy-agent: 7.0.6 + ws: 8.21.0 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-gyp-build@4.8.4: {} + node-html-parser@7.1.0: dependencies: css-select: 5.2.2 @@ -3884,8 +5690,96 @@ snapshots: dependencies: boolbase: 1.0.0 + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + obug@2.1.1: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + openai@6.39.0(ws@8.21.0)(zod@4.4.3): + optionalDependencies: + ws: 8.21.0 + zod: 4.4.3 + + openclaw@2026.5.28: + dependencies: + '@agentclientprotocol/sdk': 0.22.1(zod@4.4.3) + '@anthropic-ai/sdk': 0.98.0(zod@4.4.3) + '@clack/core': 1.3.1 + '@clack/prompts': 1.4.0 + '@earendil-works/pi-tui': 0.76.0 + '@google/genai': 2.6.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) + '@grammyjs/runner': 2.0.3(grammy@1.43.0) + '@grammyjs/transformer-throttler': 1.2.1(grammy@1.43.0) + '@homebridge/ciao': 1.3.9 + '@lydell/node-pty': 1.2.0-beta.12 + '@mistralai/mistralai': 2.2.5 + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@mozilla/readability': 0.6.0 + '@openclaw/fs-safe': 0.3.0 + '@openclaw/proxyline': 0.3.3(undici@8.3.0) + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + chokidar: 5.0.0 + clawpdf: 0.2.0 + commander: 14.0.3 + croner: 10.0.1 + cross-spawn: 7.0.6 + diff: 9.0.0 + dotenv: 17.4.2 + express: 5.2.1 + file-type: 22.0.1 + glob: 13.0.6 + grammy: 1.43.0 + highlight.js: 11.11.1 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + ipaddr.js: 2.4.0 + jiti: 2.7.0 + json5: 2.2.3 + jszip: 3.10.1 + kysely: 0.29.2 + linkedom: 0.18.12 + markdown-it: 14.2.0 + minimatch: 10.2.5 + node-edge-tts: 1.2.10 + openai: 6.39.0(ws@8.21.0)(zod@4.4.3) + partial-json: 0.1.7 + playwright-core: 1.60.0 + proper-lockfile: 4.1.2 + qrcode: 1.5.4 + quickjs-wasi: 3.0.0 + rastermill: 0.3.0 + tar: 7.5.15 + tree-sitter-bash: 0.25.1 + tslog: 4.10.2 + typebox: 1.1.38 + typescript: 6.0.3 + undici: 8.3.0 + web-push: 3.6.7 + web-tree-sitter: 0.26.9 + ws: 8.21.0 + yaml: 2.9.0 + zod: 4.4.3 + optionalDependencies: + sqlite-vec: 0.1.9 + transitivePeerDependencies: + - '@cfworker/json-schema' + - bufferutil + - canvas + - encoding + - supports-color + - tree-sitter + - utf-8-validate + outdent@0.5.0: {} p-filter@2.1.0: @@ -3902,20 +5796,36 @@ snapshots: p-map@2.1.0: {} + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + p-try@2.2.0: {} package-manager-detector@0.2.11: dependencies: quansync: 0.2.11 + pako@1.0.11: {} + + parseurl@1.3.3: {} + partial-json@0.1.7: {} path-exists@4.0.0: {} path-key@3.1.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.0 + minipass: 7.1.3 + path-to-regexp@6.3.0: {} + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} pathe@2.0.3: {} @@ -3928,6 +5838,12 @@ snapshots: pify@4.0.1: {} + pkce-challenge@5.0.1: {} + + playwright-core@1.60.0: {} + + pngjs@5.0.0: {} + postcss@8.5.10: dependencies: nanoid: 3.3.11 @@ -3936,12 +5852,67 @@ snapshots: prettier@2.8.8: {} + process-nextick-args@2.0.1: {} + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + protobufjs@7.6.1: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 25.6.0 + long: 5.3.2 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode.js@2.3.1: {} + + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + quansync@0.2.11: {} quansync@1.0.0: {} queue-microtask@1.2.3: {} + quickjs-wasi@3.0.0: {} + + range-parser@1.2.1: {} + + rastermill@0.3.0: + dependencies: + '@silvia-odwyer/photon-node': 0.3.4 + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -3949,6 +5920,18 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readdirp@5.0.0: {} + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -3977,10 +5960,20 @@ snapshots: remend@1.3.0: {} + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-main-filename@2.0.0: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} + retry@0.12.0: {} + + retry@0.13.1: {} + reusify@1.1.0: {} rolldown-plugin-dts@0.23.2(rolldown@1.0.0-rc.17)(typescript@5.9.3): @@ -4022,16 +6015,61 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} seedrandom@3.0.5: {} semver@7.7.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -4069,14 +6107,53 @@ snapshots: shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + sisteransi@1.0.5: {} + slash@3.0.0: {} source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + spawndamnit@3.0.1: dependencies: cross-spawn: 7.0.6 @@ -4084,22 +6161,84 @@ snapshots: sprintf-js@1.0.3: {} + sqlite-vec-darwin-arm64@0.1.9: + optional: true + + sqlite-vec-darwin-x64@0.1.9: + optional: true + + sqlite-vec-linux-arm64@0.1.9: + optional: true + + sqlite-vec-linux-x64@0.1.9: + optional: true + + sqlite-vec-windows-x64@0.1.9: + optional: true + + sqlite-vec@0.1.9: + optionalDependencies: + sqlite-vec-darwin-arm64: 0.1.9 + sqlite-vec-darwin-x64: 0.1.9 + sqlite-vec-linux-arm64: 0.1.9 + sqlite-vec-linux-x64: 0.1.9 + sqlite-vec-windows-x64: 0.1.9 + optional: true + stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + + statuses@2.0.2: {} + std-env@4.1.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 strip-bom@3.0.0: {} + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + supports-color@10.2.2: {} supports-color@7.2.0: dependencies: has-flag: 4.0.0 + tar@7.5.13: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + optional: true + + tar@7.5.15: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + term-size@2.2.1: {} tinybench@2.9.0: {} @@ -4117,12 +6256,27 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + tr46@0.0.3: {} tree-kill@1.2.2: {} + tree-sitter-bash@0.25.1: + dependencies: + node-addon-api: 8.8.0 + node-gyp-build: 4.8.4 + trough@2.2.0: {} + ts-algebra@2.0.0: {} + tsdown@0.21.10(typescript@5.9.3): dependencies: ansis: 4.2.0 @@ -4150,11 +6304,28 @@ snapshots: - synckit - vue-tsc - tslib@2.8.1: - optional: true + tslib@2.8.1: {} + + tslog@4.10.2: {} + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typebox@1.1.38: {} typescript@5.9.3: {} + typescript@6.0.3: {} + + uc.micro@2.1.0: {} + + uhyphen@0.2.0: {} + + uint8array-extras@1.5.0: {} + unconfig-core@7.5.0: dependencies: '@quansync/fs': 1.0.0 @@ -4166,6 +6337,8 @@ snapshots: undici@7.24.8: {} + undici@8.3.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -4201,10 +6374,16 @@ snapshots: universalify@0.1.2: {} + unpipe@1.0.0: {} + unrun@0.2.37: dependencies: rolldown: 1.0.0-rc.17 + util-deprecate@1.0.2: {} + + vary@1.1.2: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -4215,7 +6394,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7): + vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -4226,8 +6405,10 @@ snapshots: '@types/node': 20.19.39 esbuild: 0.27.7 fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 - vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7): + vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -4238,8 +6419,10 @@ snapshots: '@types/node': 22.19.17 esbuild: 0.27.7 fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 - vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7): + vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -4250,11 +6433,13 @@ snapshots: '@types/node': 25.6.0 esbuild: 0.27.7 fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 - vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)): + vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -4271,7 +6456,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7) + vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -4280,10 +6465,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)): + vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -4300,7 +6485,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@22.19.17)(esbuild@0.27.7) + vite: 8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -4309,10 +6494,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)): + vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -4329,7 +6514,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -4338,6 +6523,20 @@ snapshots: transitivePeerDependencies: - msw + web-push@3.6.7: + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.6 + jws: 4.0.1 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + + web-streams-polyfill@3.3.3: {} + + web-tree-sitter@0.26.9: {} + webidl-conversions@3.0.1: {} whatwg-url@5.0.0: @@ -4345,6 +6544,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -4378,8 +6579,63 @@ snapshots: - bufferutil - utf-8-validate + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + ws@8.18.0: {} + ws@8.21.0: {} + + y18n@4.0.3: {} + + y18n@5.0.8: {} + + yallist@5.0.0: {} + + yaml@2.9.0: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs-parser@21.1.1: {} + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 @@ -4393,6 +6649,12 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod@3.25.76: {} + zod@4.4.3: {} + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7f473eb..0b70bb6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - "packages/bridge" - "packages/chat-adapter" - "packages/cloudflare" + - "packages/openclaw" - "packages/pickle" - "packages/pi" - "packages/state-file" diff --git a/scripts/audit-package-surface.mjs b/scripts/audit-package-surface.mjs index 2b80b09..51edc7e 100644 --- a/scripts/audit-package-surface.mjs +++ b/scripts/audit-package-surface.mjs @@ -1,4 +1,4 @@ -import { readFile, readdir } from "node:fs/promises"; +import { access, readFile, readdir } from "node:fs/promises"; import { join, relative } from "node:path"; const root = new URL("..", import.meta.url).pathname; @@ -11,7 +11,13 @@ for (const entry of packages) { continue; } const packageDir = join(packagesDir, entry.name); + if (!await exists(join(packageDir, "package.json"))) { + continue; + } const packageJson = JSON.parse(await readFile(join(packageDir, "package.json"), "utf8")); + if (!packageJson.private) { + await auditPublishManifest(packageJson, packageDir); + } const sourceDir = join(packageDir, "src"); for (const file of await sourceFiles(sourceDir)) { const source = await readFile(file, "utf8"); @@ -34,6 +40,53 @@ if (failures.length > 0) { process.exit(1); } +async function exists(file) { + try { + await access(file); + return true; + } catch { + return false; + } +} + +async function auditPublishManifest(packageJson, packageDir) { + const packagePath = relative(root, join(packageDir, "package.json")); + const requiredFields = ["name", "version", "description", "license", "type", "exports", "files", "publishConfig"]; + for (const field of requiredFields) { + if (packageJson[field] === undefined) { + failures.push(`${packagePath} missing ${field}`); + } + } + + const packageDirectory = relative(root, packageDir); + if (packageJson.repository?.type !== "git") { + failures.push(`${packagePath} missing repository.type=git`); + } + if (packageJson.repository?.url !== "git+https://github.com/beeper/pickle.git") { + failures.push(`${packagePath} missing repository.url`); + } + if (packageJson.repository?.directory !== packageDirectory) { + failures.push(`${packagePath} missing repository.directory=${packageDirectory}`); + } + if (packageJson.publishConfig?.access !== "public") { + failures.push(`${packagePath} missing publishConfig.access=public`); + } + if (!packageJson.files?.includes("README.md")) { + failures.push(`${packagePath} files missing README.md`); + } else if (!await exists(join(packageDir, "README.md"))) { + failures.push(`${packagePath} lists README.md but the file is missing`); + } + if (!packageJson.files?.includes("LICENSE")) { + failures.push(`${packagePath} files missing LICENSE`); + } else if (!await exists(join(packageDir, "LICENSE"))) { + failures.push(`${packagePath} lists LICENSE but the file is missing`); + } + if (packageJson.scripts?.prepublishOnly !== "node ../../scripts/guard-pnpm-publish.mjs" + && packageJson.scripts?.prepublishOnly !== "node ../../scripts/guard-pnpm-publish.mjs && pnpm build") { + failures.push(`${packagePath} missing workspace publish guard`); + } +} + async function sourceFiles(dir) { const result = []; for (const entry of await readdir(dir, { withFileTypes: true })) { diff --git a/scripts/guard-pnpm-publish.mjs b/scripts/guard-pnpm-publish.mjs index 3d009e4..f40d4d9 100644 --- a/scripts/guard-pnpm-publish.mjs +++ b/scripts/guard-pnpm-publish.mjs @@ -2,7 +2,7 @@ const execPath = process.env.npm_execpath ?? ""; if (!execPath.includes("pnpm")) { console.error( - "Publish this workspace with `pnpm publish:packages` so workspace dependencies are rewritten for npm." + "Publish this workspace with `pnpm release` or `pnpm changeset publish` so workspace dependencies are rewritten for npm." ); process.exit(1); } diff --git a/scripts/openclaw-crabpot-full-test.mjs b/scripts/openclaw-crabpot-full-test.mjs new file mode 100644 index 0000000..8503e3c --- /dev/null +++ b/scripts/openclaw-crabpot-full-test.mjs @@ -0,0 +1,49 @@ +import { access } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const crabpotDir = resolve(process.env.CRABPOT_DIR ?? resolve(root, "..", "crabpot")); + +if (!await exists(resolve(crabpotDir, "package.json"))) { + console.error(`Missing Crabpot checkout at ${crabpotDir}`); + console.error(""); + console.error("Set it up with:"); + console.error(" git clone https://github.com/openclaw/crabpot.git ../crabpot"); + console.error(" npm --prefix ../crabpot install"); + console.error(" npm --prefix ../crabpot test"); + console.error(""); + console.error("Crabpot also expects an OpenClaw checkout at ../openclaw by default."); + console.error("Override the Crabpot location with CRABPOT_DIR=/path/to/crabpot."); + process.exit(1); +} + +console.log(`Running OpenClaw plugin compatibility tests in ${crabpotDir}`); +const child = spawn("npm", ["run", "check"], { + cwd: crabpotDir, + env: process.env, + stdio: "inherit", +}); + +child.on("exit", (code, signal) => { + if (signal) { + console.error(`Crabpot check terminated by ${signal}`); + process.exit(1); + } + process.exit(code ?? 1); +}); + +child.on("error", (error) => { + console.error(`Failed to run Crabpot check: ${error.message}`); + process.exit(1); +}); + +async function exists(file) { + try { + await access(file); + return true; + } catch { + return false; + } +} diff --git a/scripts/smoke-cloudflare-worker.mjs b/scripts/smoke-cloudflare-worker.mjs index 4ee5079..b587fe2 100644 --- a/scripts/smoke-cloudflare-worker.mjs +++ b/scripts/smoke-cloudflare-worker.mjs @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFile, spawn } from "node:child_process"; @@ -13,17 +13,16 @@ const workerDir = join(temp, "worker"); const srcDir = join(workerDir, "src"); await mkdir(packDir, { recursive: true }); -await execFileAsync( - "pnpm", - ["-r", "--filter", "@beeper/pickle", "--filter", "@beeper/pickle-cloudflare", "pack", "--pack-destination", packDir], - { cwd: rootPath } -); +const picklePackage = await readPackage(join(rootPath, "packages/pickle/package.json")); +const cloudflarePackage = await readPackage(join(rootPath, "packages/cloudflare/package.json")); +const pickleTarball = await packPackage(picklePackage.name, packDir); +const cloudflareTarball = await packPackage(cloudflarePackage.name, packDir); await mkdir(srcDir, { recursive: true }); await execFileAsync("npm", ["init", "-y"], { cwd: workerDir }); await execFileAsync("npm", [ "install", - join(packDir, "beeper-pickle-0.1.0.tgz"), - join(packDir, "beeper-pickle-cloudflare-0.1.0.tgz"), + pickleTarball, + cloudflareTarball, ], { cwd: workerDir }); await writeFile( @@ -150,6 +149,20 @@ async function waitFor(predicate, timeoutMs) { } } +async function readPackage(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +async function packPackage(packageName, destination) { + const { stdout } = await execFileAsync( + "pnpm", + ["--filter", packageName, "pack", "--pack-destination", destination, "--json"], + { cwd: rootPath } + ); + const packResult = JSON.parse(stdout); + return packResult.filename; +} + async function waitForHttp(url, timeoutMs) { const started = Date.now(); let lastError; diff --git a/scripts/smoke-consumer.mjs b/scripts/smoke-consumer.mjs index b158beb..40cc089 100644 --- a/scripts/smoke-consumer.mjs +++ b/scripts/smoke-consumer.mjs @@ -1,63 +1 @@ -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); - -const root = new URL("..", import.meta.url); -const rootPath = root.pathname; -const temp = await mkdtemp(join(tmpdir(), "pickle-consumer-")); -const packDir = join(temp, "packs"); -const consumerDir = join(temp, "consumer"); - -await mkdirp(packDir); -await execFileAsync("pnpm", ["-r", "--filter", "./packages/*", "pack", "--pack-destination", packDir], { - cwd: rootPath, -}); -await execFileAsync("npm", ["init", "-y"], { cwd: await mkdirp(consumerDir) }); - -const pickleTarball = join(packDir, "beeper-pickle-0.1.0.tgz"); -const cloudflareTarball = join(packDir, "beeper-pickle-cloudflare-0.1.0.tgz"); -const adapterTarball = join(packDir, "beeper-pickle-chat-adapter-0.1.0.tgz"); - -await execFileAsync("npm", ["install", pickleTarball, cloudflareTarball, adapterTarball, "chat@4.26.0"], { - cwd: consumerDir, -}); - -const { stdout } = await execFileAsync( - process.execPath, - [ - "--input-type=module", - "--eval", - ` - import * as pickle from "@beeper/pickle"; - import * as auth from "@beeper/pickle/auth"; - import * as beeperAuth from "@beeper/pickle/beeper/auth"; - import * as node from "@beeper/pickle/node"; - import * as cf from "@beeper/pickle-cloudflare"; - import * as adapter from "@beeper/pickle-chat-adapter"; - const checks = { - pickle: ["createMatrixClient"].every((key) => key in pickle), - auth: ["loginWithMatrixPassword", "loginWithMatrixToken"].every((key) => key in auth), - beeperAuth: ["createBeeperLogin"].every((key) => key in beeperAuth), - node: ["createMatrixClient"].every((key) => key in node), - cloudflare: ["createCloudflareKVMatrixStore", "createDurableObjectMatrixStore", "MatrixSyncDurableObject"].every((key) => key in cf), - adapter: ["createMatrixAdapter"].every((key) => key in adapter), - }; - if (!Object.values(checks).every(Boolean)) { - throw new Error(JSON.stringify(checks)); - } - console.log(JSON.stringify(checks)); - `, - ], - { cwd: consumerDir } -); - -console.log(stdout.trim()); - -async function mkdirp(path) { - await import("node:fs/promises").then(({ mkdir }) => mkdir(path, { recursive: true })); - return path; -} +import "./package-consumer-smoke.mjs"; diff --git a/tsconfig.base.json b/tsconfig.base.json index 773531e..05d007e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -19,7 +19,12 @@ "@beeper/pickle/streams": ["packages/pickle/src/streams/index.ts"], "@beeper/pickle/streams/beeper-message": ["packages/pickle/src/streams/beeper-message.ts"], "@beeper/pickle-ag-ui": ["packages/ag-ui/src/index.ts"], - "@beeper/pickle-bridge": ["packages/bridge/src/index.ts"], + "@beeper/pickle-bridge/beeper": ["packages/bridge/src/beeper.ts"], + "@beeper/pickle-bridge/beeper-stream": ["packages/bridge/src/beeper-stream.ts"], + "@beeper/pickle-bridge/bridge": ["packages/bridge/src/bridge.ts"], + "@beeper/pickle-bridge/events": ["packages/bridge/src/events.ts"], + "@beeper/pickle-bridge/media-message": ["packages/bridge/src/media-message.ts"], + "@beeper/pickle-bridge/node": ["packages/bridge/src/node.ts"], "@beeper/pickle-bridge/types": ["packages/bridge/src/types.ts"], "@beeper/pickle-chat-adapter": ["packages/chat-adapter/src/index.ts"], "@beeper/pickle-cloudflare": ["packages/cloudflare/src/index.ts"],